Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion packages/api-client/src/fetcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildApiFetcher } from "./fetcher";
import {
ApiRequestError,
buildApiFetcher,
requestErrorStatus,
} from "./fetcher";

describe("buildApiFetcher", () => {
const mockFetch = vi.fn();
Expand Down Expand Up @@ -170,4 +174,48 @@ describe("buildApiFetcher", () => {
"Network request failed",
);
});

it("throws an ApiRequestError with a typed status and the legacy message format", async () => {
mockFetch.mockResolvedValueOnce(err(404, { detail: "Not found" }));
const fetcher = buildApiFetcher({
getAccessToken: vi.fn().mockResolvedValue("token"),
refreshAccessToken: vi.fn().mockResolvedValue("new-token"),
appVersion: "test",
});

const error = await fetcher.fetch(mockInput).catch((e: unknown) => e);
expect(error).toBeInstanceOf(ApiRequestError);
expect((error as ApiRequestError).status).toBe(404);
// Catch sites across the codebase string-match on this exact format.
expect((error as ApiRequestError).message).toBe(
'Failed request: [404] {"detail":"Not found"}',
);
});

it("throws an ApiRequestError when refetching a token fails during retry", async () => {
mockFetch.mockResolvedValueOnce(err(401));
const fetcher = buildApiFetcher({
getAccessToken: vi.fn().mockResolvedValue("token"),
refreshAccessToken: vi.fn().mockRejectedValueOnce(new Error("failed")),
appVersion: "test",
});

const error = await fetcher.fetch(mockInput).catch((e: unknown) => e);
expect(error).toBeInstanceOf(ApiRequestError);
expect((error as ApiRequestError).status).toBe(401);
});
});

describe("requestErrorStatus", () => {
it("returns the status of an ApiRequestError", () => {
expect(requestErrorStatus(new ApiRequestError(404, "{}"))).toBe(404);
});

it("returns undefined for plain errors and non-errors", () => {
expect(requestErrorStatus(new Error("Failed request: [404] x"))).toBe(
undefined,
);
expect(requestErrorStatus("Failed request: [404] x")).toBe(undefined);
expect(requestErrorStatus(null)).toBe(undefined);
});
});
30 changes: 26 additions & 4 deletions packages/api-client/src/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@ export type ApiFetcherConfig = {
appVersion: string;
};

/**
* Non-2xx HTTP response from the PostHog API. Keeps the legacy
* `Failed request: [<status>] <body>` message format — many catch sites
* string-match on it — while exposing the status as a typed field.
*/
export class ApiRequestError extends Error {
readonly status: number;

constructor(status: number, serializedBody: string) {
super(`Failed request: [${status}] ${serializedBody}`);
this.name = "ApiRequestError";
this.status = status;
}
}

/** HTTP status of an ApiRequestError, or undefined for any other error. */
export function requestErrorStatus(error: unknown): number | undefined {
return error instanceof ApiRequestError ? error.status : undefined;
}

export const buildApiFetcher: (
config: ApiFetcherConfig,
) => Parameters<typeof createApiClient>[0] = (config) => {
Expand Down Expand Up @@ -91,8 +111,9 @@ export const buildApiFetcher: (
.catch(() =>
cloned.text().then((t) => ({ error: t || `${response.status}` })),
);
throw new Error(
`Failed request: [${response.status}] ${JSON.stringify(errorResponse)}`,
throw new ApiRequestError(
response.status,
JSON.stringify(errorResponse),
);
}
}
Expand All @@ -104,8 +125,9 @@ export const buildApiFetcher: (
.catch(() =>
cloned.text().then((t) => ({ error: t || `${response.status}` })),
);
throw new Error(
`Failed request: [${response.status}] ${JSON.stringify(errorResponse)}`,
throw new ApiRequestError(
response.status,
JSON.stringify(errorResponse),
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,18 @@ export function AgentApprovalsPane({
filter: ApprovalFilter;
onFilterChange: (f: ApprovalFilter) => void;
}) {
const { data, isLoading, isError, isFetching, dataUpdatedAt, refetch } =
useAgentApplicationApprovals(
idOrSlug,
filter === "all" ? undefined : { state: filter },
);
const {
data,
isLoading,
isError,
isPermissionError,
isFetching,
dataUpdatedAt,
refetch,
} = useAgentApplicationApprovals(
idOrSlug,
filter === "all" ? undefined : { state: filter },
);
const approvals = useMemo(() => data ?? [], [data]);
const selected = selectedId
? (approvals.find((a) => a.id === selectedId) ?? null)
Expand Down Expand Up @@ -78,10 +85,17 @@ export function AgentApprovalsPane({
/>
))}
</Flex>
) : isPermissionError ? (
// A 404 here means the admin gate: AgentDetailLayout only renders this
// pane's content once the application itself has loaded successfully.
<AgentDetailEmptyState
title="You need organization admin access"
description="Tool approvals can only be viewed and decided by organization admins. Ask an admin to review pending requests."
/>
) : isError ? (
<AgentDetailEmptyState
title="Couldn't load approvals"
description="The agent platform API returned an error. Approvals are team-admin only — you may not have access."
description="The agent platform API returned an error while loading approvals. Try again shortly."
/>
) : approvals.length === 0 ? (
<AgentDetailEmptyState
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,15 @@ export function AgentFleetApprovalsPane({
filter: ApprovalFilter;
onFilterChange: (f: ApprovalFilter) => void;
}) {
const { data, isLoading, isError, isFetching, dataUpdatedAt, refetch } =
useAgentFleetApprovals(filter === "all" ? undefined : { state: filter });
const {
data,
isLoading,
isError,
isPermissionError,
isFetching,
dataUpdatedAt,
refetch,
} = useAgentFleetApprovals(filter === "all" ? undefined : { state: filter });
const { data: applications } = useAgentApplications();

const approvals = useMemo(() => data ?? [], [data]);
Expand Down Expand Up @@ -109,10 +116,15 @@ export function AgentFleetApprovalsPane({
/>
))}
</Flex>
) : isPermissionError ? (
<AgentDetailEmptyState
title="You need organization admin access"
description="Tool approvals can only be viewed and decided by organization admins. Ask an admin to review pending requests."
/>
) : isError ? (
<AgentDetailEmptyState
title="Couldn't load approvals"
description="The agent platform API returned an error. Fleet approvals are team-admin only — you may not have access."
description="The agent platform API returned an error while loading fleet approvals. Try again shortly."
/>
) : approvals.length === 0 ? (
<AgentDetailEmptyState
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { requestErrorStatus } from "@posthog/api-client/fetcher";

/**
* The backend gates the approvals endpoints behind an org-membership ADMIN
* check and answers 404 (not 403) for non-admins, so a 404 from these
* endpoints means "no permission". For the per-agent endpoint this only
* holds when the application is known to exist — AgentApprovalsPane renders
* inside AgentDetailLayout, which gates on the application having loaded.
*/
export function isApprovalsPermissionError(error: unknown): boolean {
return requestErrorStatus(error) === 404;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { ApiRequestError } from "@posthog/api-client/fetcher";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mockListAgentApplicationApprovals = vi.hoisted(() => vi.fn());
const mockClient = vi.hoisted(() => ({
listAgentApplicationApprovals: mockListAgentApplicationApprovals,
}));

vi.mock("@posthog/ui/features/auth/authClient", () => ({
useOptionalAuthenticatedClient: () => mockClient,
}));

vi.mock("../../auth/store", () => ({
useAuthStateValue: <T,>(
selector: (state: { currentProjectId: number }) => T,
) => selector({ currentProjectId: 2 }),
}));

import { useAgentApplicationApprovals } from "./useAgentApplicationApprovals";

function renderApprovalsHook(idOrSlug: string) {
const queryClient = new QueryClient({
// Zero out the backoff so the hook's own `retry` option (under test)
// drives the call count without slowing the suite.
defaultOptions: { queries: { retryDelay: 0 } },
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return renderHook(() => useAgentApplicationApprovals(idOrSlug), { wrapper });
}

describe("useAgentApplicationApprovals", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("flags a 404 as a permission error without retrying", async () => {
mockListAgentApplicationApprovals.mockRejectedValue(
new ApiRequestError(404, '{"detail":"Not found"}'),
);
const { result } = renderApprovalsHook("my-agent");

await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.isPermissionError).toBe(true);
// The admin gate never clears on retry, so the hook must not retry.
expect(mockListAgentApplicationApprovals).toHaveBeenCalledTimes(1);
});

it("treats non-404 failures as genuine errors and retries them", async () => {
mockListAgentApplicationApprovals.mockRejectedValue(
new ApiRequestError(500, '{"error":"boom"}'),
);
const { result } = renderApprovalsHook("my-agent");

await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.isPermissionError).toBe(false);
expect(mockListAgentApplicationApprovals).toHaveBeenCalledTimes(4);
});

it("returns approvals on success", async () => {
mockListAgentApplicationApprovals.mockResolvedValue([{ id: "approval-1" }]);
const { result } = renderApprovalsHook("my-agent");

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([{ id: "approval-1" }]);
expect(result.current.isPermissionError).toBe(false);
});

it("stays disabled and never fetches without an idOrSlug", async () => {
mockListAgentApplicationApprovals.mockResolvedValue([]);
const { result } = renderApprovalsHook("");

// enabled gates on !!idOrSlug — the query must stay pending with no fetch.
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockListAgentApplicationApprovals).not.toHaveBeenCalled();
expect(result.current.isPending).toBe(true);
expect(result.current.isPermissionError).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,36 @@ import type {
import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery";
import { useAuthStateValue } from "../../auth/store";
import { agentApplicationsKeys } from "./agentApplicationsKeys";
import { isApprovalsPermissionError } from "./approvalsPermission";

/**
* Lists tool-approval requests for one agent. Optionally filtered to a single
* state (the backend accepts one `state` value); omit for all states.
* Lists tool-approval requests for one agent (organization-admin only).
* Optionally filtered to a single state (the backend accepts one `state`
* value); omit for all states. `isPermissionError` is true when the viewer
* lacks admin access.
*/
export function useAgentApplicationApprovals(
idOrSlug: string,
params?: AgentApprovalsListParams,
) {
const projectId = useAuthStateValue((state) => state.currentProjectId);
return useAuthenticatedQuery<AgentApprovalRequest[]>(
const query = useAuthenticatedQuery<AgentApprovalRequest[]>(
agentApplicationsKeys.approvals(projectId, idOrSlug, params?.state),
(client) => client.listAgentApplicationApprovals(idOrSlug, params),
{
enabled: !!projectId && !!idOrSlug,
staleTime: 10_000,
// Queued approvals change as agents run; poll while the tab is focused.
refetchInterval: 10_000,
// Queued approvals change as agents run; poll while the tab is focused —
// but stop once we know the viewer lacks org-admin access.
refetchInterval: (q) =>
isApprovalsPermissionError(q.state.error) ? false : 10_000,
// A 404 here is the admin gate, not a transient failure — don't retry.
retry: (failureCount, error) =>
!isApprovalsPermissionError(error) && failureCount < 3,
},
);
return {
...query,
isPermissionError: isApprovalsPermissionError(query.error),
};
}
Loading
Loading