Files
Arnaud 524310433c fix: return clean errors and refund quota on AI provider failures
AI routes had no consistent error handling — a provider failure (e.g.
OpenRouter returning a degraded-model error) crashed the route as an
uncaught 500 and, worse, still charged the user's monthly aiCall
quota for a request that never succeeded.

Adds withAiQuota()/aiErrorResponse() (lib/ai/ai-error.ts) and applies
them across all 12 AI generation routes: charges the quota, runs the
AI call, refunds the credit and returns a clean user-facing message
if it throws. Frontend needs no changes — existing dialogs already
toast whatever `error` string comes back.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 12:32:31 +02:00

84 lines
2.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { APICallError } from "ai";
const { mockCheckAndIncrement, mockRefund } = vi.hoisted(() => ({
mockCheckAndIncrement: vi.fn(),
mockRefund: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/tiers", async () => {
const actual = await vi.importActual<typeof import("@/lib/tiers")>("@/lib/tiers");
return {
...actual,
checkAndIncrementTierLimit: mockCheckAndIncrement,
refundAiCall: mockRefund,
};
});
const { aiErrorResponse, withAiQuota } = await import("../ai-error");
const { TierLimitError } = await import("@/lib/tiers");
beforeEach(() => {
vi.clearAllMocks();
mockCheckAndIncrement.mockResolvedValue(undefined);
});
function makeApiCallError(isRetryable: boolean) {
return new APICallError({
message: "Provider returned error",
url: "https://openrouter.ai/api/v1/responses",
requestBodyValues: {},
statusCode: isRetryable ? 503 : 400,
isRetryable,
});
}
describe("aiErrorResponse", () => {
it("maps a retryable APICallError to a 503 with a friendly message", async () => {
const res = aiErrorResponse(makeApiCallError(true));
expect(res.status).toBe(503);
const body = await res.json() as { error: string; retryable: boolean };
expect(body.retryable).toBe(true);
expect(body.error).not.toMatch(/openrouter|nvidia|nemotron/i);
});
it("maps a non-retryable APICallError to a 502", async () => {
const res = aiErrorResponse(makeApiCallError(false));
expect(res.status).toBe(502);
const body = await res.json() as { retryable: boolean };
expect(body.retryable).toBe(false);
});
it("maps an unknown error to a generic 502", async () => {
const res = aiErrorResponse(new Error("boom"));
expect(res.status).toBe(502);
});
});
describe("withAiQuota", () => {
it("returns 403 when the tier limit is already reached, without calling fn", async () => {
mockCheckAndIncrement.mockRejectedValue(new TierLimitError("aiCall", "free"));
const fn = vi.fn();
const result = await withAiQuota("user-1", "free", fn);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.response.status).toBe(403);
expect(fn).not.toHaveBeenCalled();
});
it("refunds the charged credit when fn throws", async () => {
const fn = vi.fn().mockRejectedValue(makeApiCallError(true));
const result = await withAiQuota("user-1", "free", fn);
expect(result.ok).toBe(false);
expect(mockRefund).toHaveBeenCalledWith("user-1");
if (!result.ok) expect(result.response.status).toBe(503);
});
it("returns fn's result and does not refund on success", async () => {
const fn = vi.fn().mockResolvedValue({ title: "Pasta" });
const result = await withAiQuota("user-1", "free", fn);
expect(result.ok).toBe(true);
if (result.ok) expect(result.data).toEqual({ title: "Pasta" });
expect(mockRefund).not.toHaveBeenCalled();
});
});