Skip to content

Commit 8369213

Browse files
committed
feat: surface rate-limit details from the AI backend
Map an upstream 429 to a clear "rate limited" message with the retry-after delay, and 503/529 to an "overloaded" message, instead of returning an opaque server error.
1 parent 878ef6b commit 8369213

2 files changed

Lines changed: 82 additions & 1 deletion

File tree

api/ai.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { describeTurnError } from "./ai";
4+
5+
describe("describeTurnError", () => {
6+
it("maps a 429 with a Headers retry-after to a rate_limit error with timing", () => {
7+
const err = Object.assign(new Error("Too Many Requests"), {
8+
status: 429,
9+
headers: new Headers({ "retry-after": "42" }),
10+
});
11+
const out = describeTurnError(err);
12+
expect(out.code).toBe("rate_limit");
13+
expect(out.message).toMatch(/rate limited/i);
14+
expect(out.retryAfter).toBe(42);
15+
});
16+
17+
it("reads retry-after from a plain-record headers object", () => {
18+
const err = { status: 429, headers: { "retry-after": "10" }, message: "rl" };
19+
const out = describeTurnError(err);
20+
expect(out.code).toBe("rate_limit");
21+
expect(out.retryAfter).toBe(10);
22+
});
23+
24+
it("omits retryAfter when the header is missing or non-numeric", () => {
25+
expect(describeTurnError({ status: 429, headers: {} }).retryAfter).toBeUndefined();
26+
expect(
27+
describeTurnError({ status: 429, headers: { "retry-after": "soon" } }).retryAfter,
28+
).toBeUndefined();
29+
});
30+
31+
it("maps 503 and 529 to an overloaded error", () => {
32+
expect(describeTurnError({ status: 503 }).code).toBe("overloaded");
33+
expect(describeTurnError({ status: 529 }).code).toBe("overloaded");
34+
});
35+
36+
it("falls back to a server_error with the message for other failures", () => {
37+
expect(describeTurnError(new Error("boom"))).toEqual({
38+
code: "server_error",
39+
message: "boom",
40+
});
41+
});
42+
});

api/ai.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,50 @@ export async function handleAiRequest(args: {
5656
);
5757
} catch (err) {
5858
console.error("[api/ai] turn failed:", err);
59-
sink.send("error", { code: "server_error", message: (err as Error).message });
59+
sink.send("error", describeTurnError(err));
6060
sink.end();
6161
}
6262
}
6363

64+
// Reads the `retry-after` header (seconds) from an upstream error, tolerating
65+
// both a Headers object and a plain record.
66+
function retryAfterSeconds(err: unknown): number | undefined {
67+
const headers = (err as { headers?: unknown }).headers;
68+
let raw: string | null | undefined;
69+
if (headers && typeof (headers as Headers).get === "function") {
70+
raw = (headers as Headers).get("retry-after");
71+
} else if (headers && typeof headers === "object") {
72+
raw = (headers as Record<string, string>)["retry-after"];
73+
}
74+
const n = Number(raw);
75+
return Number.isFinite(n) && n > 0 ? n : undefined;
76+
}
77+
78+
// Maps an error thrown during the turn to a client-facing SSE error. Upstream
79+
// rate limits and overloads get a clear, actionable message (and retry timing)
80+
// instead of an opaque failure.
81+
export function describeTurnError(err: unknown): {
82+
code: string;
83+
message: string;
84+
retryAfter?: number;
85+
} {
86+
const status = (err as { status?: number }).status;
87+
if (status === 429) {
88+
return {
89+
code: "rate_limit",
90+
message: "The AI service is rate limited. Please wait a moment and try again.",
91+
retryAfter: retryAfterSeconds(err),
92+
};
93+
}
94+
if (status === 503 || status === 529) {
95+
return {
96+
code: "overloaded",
97+
message: "The AI service is temporarily overloaded. Please try again shortly.",
98+
};
99+
}
100+
return { code: "server_error", message: (err as Error).message || "The request failed." };
101+
}
102+
64103
function clientIp(req: VercelRequest): string {
65104
const realIp = req.headers["x-real-ip"];
66105
if (typeof realIp === "string" && realIp.trim()) return realIp.trim();

0 commit comments

Comments
 (0)