Skip to content

Commit fd1f4b8

Browse files
committed
feat(loops): surface safety-limit errors distinctly to the user
1 parent 8ab3fc8 commit fd1f4b8

4 files changed

Lines changed: 106 additions & 3 deletions

File tree

packages/api-client/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ export {
66
createLoop,
77
destroyLoop,
88
type LoopEndpoints,
9+
type LoopSafetyLimitBody,
910
type LoopSchemas,
11+
LoopsApiError,
1012
listLoopRuns,
1113
listLoops,
1214
partialUpdateLoop,

packages/api-client/src/loops.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
createLoop,
55
destroyLoop,
66
type LoopSchemas,
7+
LoopsApiError,
78
listLoopRuns,
89
listLoops,
910
partialUpdateLoop,
@@ -203,4 +204,43 @@ describe("loops client", () => {
203204
"[404]",
204205
);
205206
});
207+
208+
it("throws LoopsApiError carrying the parsed body on a rejected request", async () => {
209+
const { fetcher } = fakeFetcher(
210+
{
211+
error: "loop_safety_limit",
212+
code: "max_loops_per_team",
213+
limit: 100,
214+
detail: "This project has reached the limit of 100 loops.",
215+
},
216+
429,
217+
);
218+
const client = createApiClient(fetcher, BASE_URL);
219+
220+
const error = await createLoop(
221+
client,
222+
PROJECT_ID,
223+
MINIMAL_LOOP_WRITE,
224+
).catch((e) => e);
225+
226+
expect(error).toBeInstanceOf(LoopsApiError);
227+
expect((error as LoopsApiError).status).toBe(429);
228+
const safetyLimit = (error as LoopsApiError).safetyLimit;
229+
expect(safetyLimit?.code).toBe("max_loops_per_team");
230+
expect(safetyLimit?.detail).toContain("100 loops");
231+
});
232+
233+
it("leaves safetyLimit null for a generic (non-limit) error body", async () => {
234+
const { fetcher } = fakeFetcher({ detail: "Something broke" }, 500);
235+
const client = createApiClient(fetcher, BASE_URL);
236+
237+
const error = await createLoop(
238+
client,
239+
PROJECT_ID,
240+
MINIMAL_LOOP_WRITE,
241+
).catch((e) => e);
242+
243+
expect(error).toBeInstanceOf(LoopsApiError);
244+
expect((error as LoopsApiError).safetyLimit).toBeNull();
245+
});
206246
});

packages/api-client/src/loops.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -367,14 +367,65 @@ async function loopsRequest<T>(
367367
});
368368

369369
if (!response.ok) {
370-
throw new Error(
371-
`Loops API request failed: ${method.toUpperCase()} ${path} [${response.status}]`,
370+
throw new LoopsApiError(
371+
method,
372+
path,
373+
response.status,
374+
await readBody(response),
372375
);
373376
}
374377

375378
return (await parseResponseData(response)) as T;
376379
}
377380

381+
/** Machine-readable body of a rejected loop safety/abuse limit (see the backend's
382+
* `_loop_limit_response`). `error === "loop_safety_limit"` is the stable marker. */
383+
export interface LoopSafetyLimitBody {
384+
error: "loop_safety_limit";
385+
code: string;
386+
limit: number;
387+
detail: string;
388+
}
389+
390+
/** Error thrown for any non-2xx loops response, carrying the status and parsed body so
391+
* callers can distinguish a safety-limit rejection from a generic failure. */
392+
export class LoopsApiError extends Error {
393+
readonly status: number;
394+
readonly body: unknown;
395+
396+
constructor(method: string, path: string, status: number, body: unknown) {
397+
super(
398+
`Loops API request failed: ${method.toUpperCase()} ${path} [${status}]`,
399+
);
400+
this.name = "LoopsApiError";
401+
this.status = status;
402+
this.body = body;
403+
}
404+
405+
/** The parsed safety-limit body when this is a limit rejection, otherwise null. */
406+
get safetyLimit(): LoopSafetyLimitBody | null {
407+
const body = this.body;
408+
if (
409+
body != null &&
410+
typeof body === "object" &&
411+
(body as { error?: unknown }).error === "loop_safety_limit"
412+
) {
413+
return body as LoopSafetyLimitBody;
414+
}
415+
return null;
416+
}
417+
}
418+
419+
async function readBody(response: Response): Promise<unknown> {
420+
// Only called on the error path, where nothing else consumes the body, so read
421+
// it directly rather than cloning.
422+
try {
423+
return await response.json();
424+
} catch {
425+
return null;
426+
}
427+
}
428+
378429
export async function listLoops(
379430
client: ApiClient,
380431
projectId: string,

packages/ui/src/features/loops/components/LoopForm.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
CaretRight,
55
Check,
66
} from "@phosphor-icons/react";
7-
import type { LoopSchemas } from "@posthog/api-client/loops";
7+
import { type LoopSchemas, LoopsApiError } from "@posthog/api-client/loops";
88
import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect";
99
import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent";
1010
import { Button } from "@posthog/ui/primitives/Button";
@@ -125,6 +125,16 @@ export function LoopForm({ loop }: LoopFormProps) {
125125
navigateToLoopDetail(created.id);
126126
}
127127
} catch (error) {
128+
const safetyLimit =
129+
error instanceof LoopsApiError ? error.safetyLimit : null;
130+
if (safetyLimit) {
131+
// A safety/abuse ceiling, not a normal failure: tell the user plainly so they can
132+
// course-correct (delete a loop, remove triggers) or contact support for a raise.
133+
toast.error("Safety limit reached", {
134+
description: safetyLimit.detail,
135+
});
136+
return;
137+
}
128138
toast.error(isEdit ? "Failed to save loop" : "Failed to create loop", {
129139
description: error instanceof Error ? error.message : undefined,
130140
});

0 commit comments

Comments
 (0)