mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 18:23:47 +10:00
fix(api): translate copilot AI provider failures to BAD_GATEWAY (#3333)
* fix(api): translate copilot AI provider failures to BAD_GATEWAY
- Problem: AI provider errors (bad key, unknown model, quota, 5xx) from the
AI SDK bubble out as opaque 500 INTERNAL_SERVER_ERROR from copilot
endpoints (autofill, match-score, draft-message, tailor-resume).
- Fix: catch AISDKError in generatePlainText and a local generateJson
wrapper that delegates to the shared generate-json module, translating
both to BAD_GATEWAY (502) with the original error preserved as cause.
Mirrors the existing pattern in features/ai/router.ts.
- Verification: vitest (CI — requires Node 22+). Test file unchanged in
assertion logic from the original PR; the local generateJson now
wraps the shared module instead of duplicating it.
Rebased onto main after v5.2.9 AI-layer refactor (generateJson extracted
into features/ai/generate-json.ts).
* fix(api): align generateJson prompt shape with callers and shared module
- Problem: local generateJson wrapper accepted (model, prompt: string,
schema) but all callers pass (model, { prompt: string }, schema).
Caught by CodeRabbit review.
- Fix: match the shared generate-json module signature — accept
{ system?, prompt } as the second argument and pass it through.
Updated test calls to match.
* fix(test): remove stray leading dots from mock object property names
- Problem: rebase onto v5.2.9 introduced `.use`, `.output`, `.errors`
as property names in the chain mock object, which is invalid JS
syntax and would cause a parse error when tests run.
- Fix: remove the leading dots to restore valid property names.
- Verification: cat -A confirms tabs-only indentation, no leading dots.
* fix(docs): correct 'a actionable' to 'an actionable' in comment
- Problem: Grammar typo in inline comment.
- Fix: 'a actionable' → 'an actionable'.
- Verification: grep confirms no remaining instances.
* fix(api): narrow copilot AI BAD_GATEWAY predicate to APICallError and exhausted RetryError
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { APICallError, generateText, LoadAPIKeyError, RetryError } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
const protectedProcedureMock = vi.hoisted(() => {
|
||||
const chain = {
|
||||
@@ -6,12 +8,16 @@ const protectedProcedureMock = vi.hoisted(() => {
|
||||
input: vi.fn(() => chain),
|
||||
use: vi.fn(() => chain),
|
||||
output: vi.fn(() => chain),
|
||||
errors: vi.fn(() => chain),
|
||||
handler: vi.fn(() => chain),
|
||||
};
|
||||
return chain;
|
||||
});
|
||||
|
||||
vi.mock("ai", () => ({ generateText: vi.fn() }));
|
||||
vi.mock("ai", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("ai")>()),
|
||||
generateText: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../context", () => ({ protectedProcedure: protectedProcedureMock }));
|
||||
vi.mock("../../middleware/rate-limit", () => ({ aiRequestRateLimit: vi.fn() }));
|
||||
vi.mock("../ai/service", () => ({ getModel: vi.fn() }));
|
||||
@@ -21,7 +27,7 @@ vi.mock("./service", () => ({
|
||||
applicationService: { getById: vi.fn(), setAiResult: vi.fn(), update: vi.fn(), addNote: vi.fn() },
|
||||
}));
|
||||
|
||||
const { autofillInputSchema } = await import("./ai");
|
||||
const { autofillInputSchema, generateJson, generatePlainText } = await import("./ai");
|
||||
|
||||
describe("autofillInputSchema", () => {
|
||||
it("rejects oversized pasted job descriptions", () => {
|
||||
@@ -39,3 +45,95 @@ describe("autofillInputSchema", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("copilot provider-failure translation", () => {
|
||||
const schema = z.object({ summary: z.string() });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(generateText).mockReset();
|
||||
});
|
||||
|
||||
it("translates APICallError provider failures to BAD_GATEWAY in generatePlainText", async () => {
|
||||
vi.mocked(generateText).mockRejectedValue(
|
||||
new APICallError({
|
||||
message: "Provider returned 401",
|
||||
url: "https://api.openai.com/v1/chat/completions",
|
||||
requestBodyValues: undefined,
|
||||
statusCode: 401,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(generatePlainText({} as never, "prompt")).rejects.toMatchObject({ code: "BAD_GATEWAY" });
|
||||
});
|
||||
|
||||
it("translates APICallError provider failures to BAD_GATEWAY in generateJson", async () => {
|
||||
vi.mocked(generateText).mockRejectedValue(
|
||||
new APICallError({
|
||||
message: "Model not found",
|
||||
url: "https://api.openai.com/v1/chat/completions",
|
||||
requestBodyValues: undefined,
|
||||
statusCode: 404,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(generateJson({} as never, { prompt: "prompt" }, schema)).rejects.toMatchObject({
|
||||
code: "BAD_GATEWAY",
|
||||
});
|
||||
});
|
||||
|
||||
it("translates RetryError with maxRetriesExceeded to BAD_GATEWAY", async () => {
|
||||
const providerError = new APICallError({
|
||||
message: "Provider returned 500",
|
||||
url: "https://api.openai.com/v1/chat/completions",
|
||||
requestBodyValues: undefined,
|
||||
statusCode: 500,
|
||||
});
|
||||
vi.mocked(generateText).mockRejectedValue(
|
||||
new RetryError({
|
||||
message: "Failed to generate text after 3 attempts",
|
||||
reason: "maxRetriesExceeded",
|
||||
errors: [providerError],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(generatePlainText({} as never, "prompt")).rejects.toMatchObject({ code: "BAD_GATEWAY" });
|
||||
});
|
||||
|
||||
it("preserves the provider error as the BAD_GATEWAY cause", async () => {
|
||||
const providerError = new APICallError({
|
||||
message: "quota exceeded",
|
||||
url: "https://api.openai.com/v1/chat/completions",
|
||||
requestBodyValues: undefined,
|
||||
statusCode: 429,
|
||||
});
|
||||
vi.mocked(generateText).mockRejectedValue(providerError);
|
||||
|
||||
const error: { code?: string; cause?: unknown } = await generatePlainText({} as never, "prompt").catch(
|
||||
(thrown) => thrown,
|
||||
);
|
||||
expect(error.code).toBe("BAD_GATEWAY");
|
||||
expect(error.cause).toBe(providerError);
|
||||
});
|
||||
|
||||
it("rethrows non-provider SDK errors unchanged", async () => {
|
||||
const credentialError = new LoadAPIKeyError({ message: "The OPENAI_API_KEY is not set" });
|
||||
vi.mocked(generateText).mockRejectedValue(credentialError);
|
||||
|
||||
await expect(generatePlainText({} as never, "prompt")).rejects.toBe(credentialError);
|
||||
await expect(generateJson({} as never, { prompt: "prompt" }, schema)).rejects.toBe(credentialError);
|
||||
});
|
||||
|
||||
it("rethrows non-AI errors unchanged", async () => {
|
||||
const unrelated = new Error("network dropped mid-call");
|
||||
vi.mocked(generateText).mockRejectedValue(unrelated);
|
||||
|
||||
await expect(generatePlainText({} as never, "prompt")).rejects.toBe(unrelated);
|
||||
await expect(generateJson({} as never, { prompt: "prompt" }, schema)).rejects.toBe(unrelated);
|
||||
});
|
||||
|
||||
it("still returns parsed JSON on success", async () => {
|
||||
vi.mocked(generateText).mockResolvedValue({ text: '```json\n{"summary":"<p>Hi</p>"}\n```' } as never);
|
||||
|
||||
await expect(generateJson({} as never, { prompt: "prompt" }, schema)).resolves.toEqual({ summary: "<p>Hi</p>" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { generateText } from "ai";
|
||||
import { APICallError, generateText, RetryError } from "ai";
|
||||
import z from "zod";
|
||||
import { generateId, slugify } from "@reactive-resume/utils/string";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { aiRequestRateLimit } from "../../middleware/rate-limit";
|
||||
import { generateJson } from "../ai/generate-json";
|
||||
import { generateJson as sharedGenerateJson } from "../ai/generate-json";
|
||||
import { getModel } from "../ai/service";
|
||||
import { aiProvidersService } from "../ai-providers/service";
|
||||
import { resumeService } from "../resume/service";
|
||||
@@ -29,11 +29,55 @@ async function resolveModel(userId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
async function generatePlainText(model: Awaited<ReturnType<typeof resolveModel>>, prompt: string) {
|
||||
const { text } = await generateText({ model, messages: [{ role: "user", content: prompt }] });
|
||||
return text.trim();
|
||||
// --- AI provider failure translation ------------------------------------------
|
||||
// The AI SDK surfaces provider-side failures as `APICallError` (HTTP 4xx/5xx from
|
||||
// the provider) or `RetryError` with `reason: "maxRetriesExceeded"`. Translating
|
||||
// only those to BAD_GATEWAY gives the client an actionable status code instead of
|
||||
// an opaque 500. Validation, credential, model-resolution, and response-parsing
|
||||
// errors rethrow unchanged.
|
||||
|
||||
function isAiProviderGatewayError(error: unknown): boolean {
|
||||
if (APICallError.isInstance(error)) return true;
|
||||
if (RetryError.isInstance(error) && error.reason === "maxRetriesExceeded") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Throws a BAD_GATEWAY ORPCError, preserving the original cause for upstream error reporters. */
|
||||
function throwAiProviderGatewayError(cause?: unknown): never {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider.", cause });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around the shared `generateJson` that translates AI provider failures
|
||||
* to BAD_GATEWAY. Accepts the same prompt shape as the shared module.
|
||||
* Exported for tests.
|
||||
*/
|
||||
export async function generateJson<T>(
|
||||
model: Awaited<ReturnType<typeof resolveModel>>,
|
||||
prompt: { system?: string; prompt: string },
|
||||
schema: z.ZodType<T>,
|
||||
) {
|
||||
try {
|
||||
return await sharedGenerateJson(model, prompt, schema);
|
||||
} catch (error) {
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Exported for tests: provider-failure translation shared by every copilot procedure. */
|
||||
export async function generatePlainText(model: Awaited<ReturnType<typeof resolveModel>>, prompt: string) {
|
||||
try {
|
||||
const { text } = await generateText({ model, messages: [{ role: "user", content: prompt }] });
|
||||
return text.trim();
|
||||
} catch (error) {
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Schema & router -----------------------------------------------------------
|
||||
|
||||
const autofillOutput = z.object({
|
||||
company: z.string(),
|
||||
role: z.string(),
|
||||
@@ -61,6 +105,11 @@ const matchScoreOutput = z.object({
|
||||
.transform((a) => a.slice(0, 8)),
|
||||
});
|
||||
|
||||
const aiErrors = {
|
||||
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
|
||||
BAD_REQUEST: { message: "Invalid application or AI request.", status: 400 },
|
||||
};
|
||||
|
||||
export const aiRouter = {
|
||||
// Extract structured fields from a pasted job description. The posting text itself is stored
|
||||
// verbatim on the application, so nothing here fetches or scrapes a URL.
|
||||
@@ -69,6 +118,7 @@ export const aiRouter = {
|
||||
.input(autofillInputSchema)
|
||||
.use(aiRequestRateLimit)
|
||||
.output(autofillOutput)
|
||||
.errors(aiErrors)
|
||||
.handler(async ({ context, input }) => {
|
||||
const model = await resolveModel(context.user.id);
|
||||
|
||||
@@ -92,6 +142,7 @@ export const aiRouter = {
|
||||
.input(z.object({ id: z.string() }))
|
||||
.use(aiRequestRateLimit)
|
||||
.output(matchScoreOutput)
|
||||
.errors(aiErrors)
|
||||
.handler(async ({ context, input }) => {
|
||||
const application = await applicationService.getById({ id: input.id, userId: context.user.id });
|
||||
if (!application.resumeId)
|
||||
@@ -134,6 +185,7 @@ export const aiRouter = {
|
||||
.input(z.object({ id: z.string(), kind: z.enum(["cover-letter", "follow-up"]) }))
|
||||
.use(aiRequestRateLimit)
|
||||
.output(z.object({ text: z.string() }))
|
||||
.errors(aiErrors)
|
||||
.handler(async ({ context, input }) => {
|
||||
const application = await applicationService.getById({ id: input.id, userId: context.user.id });
|
||||
const model = await resolveModel(context.user.id);
|
||||
@@ -162,6 +214,7 @@ export const aiRouter = {
|
||||
.input(z.object({ id: z.string() }))
|
||||
.use(aiRequestRateLimit)
|
||||
.output(z.object({ resumeId: z.string(), name: z.string() }))
|
||||
.errors(aiErrors)
|
||||
.handler(async ({ context, input }) => {
|
||||
const application = await applicationService.getById({ id: input.id, userId: context.user.id });
|
||||
if (!application.resumeId)
|
||||
|
||||
Reference in New Issue
Block a user