From 855d38868e1b09d99ab0f0000669fb1e314ef8ec Mon Sep 17 00:00:00 2001 From: Luca Steeb Date: Fri, 5 Jun 2026 15:15:17 +0200 Subject: [PATCH] fix(gateway): don't log forwarded upstream errors as errors When image generation forwards to chat completions and the provider returns an upstream error (e.g. google-vertex 429), images.ts re-threw it as an HTTPException(500), which app.onError logged at ERROR severity and tripped error alerting. Mark these forwarded upstream/gateway provider errors via the exception cause so the global handler surfaces them as warnings. They are already recorded in the log table with the correct unifiedFinishReason by the internal chat-completions call, so no DB classification change is needed. Co-Authored-By: Claude Opus 4.8 --- apps/gateway/src/app.ts | 16 +++++++++-- apps/gateway/src/images/images.ts | 12 +++++++- apps/gateway/src/lib/error-response.spec.ts | 25 +++++++++++++++++ apps/gateway/src/lib/error-response.ts | 31 +++++++++++++++++++++ 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/apps/gateway/src/app.ts b/apps/gateway/src/app.ts index 255f421685..065d309909 100644 --- a/apps/gateway/src/app.ts +++ b/apps/gateway/src/app.ts @@ -30,6 +30,7 @@ import { imagesRoute } from "./images/route.js"; import { buildAnthropicErrorBody, buildOpenAIErrorBody, + getUpstreamErrorCause, } from "./lib/error-response.js"; import { mcpHandler, registerMcpOAuthRoutes } from "./mcp/mcp.js"; import { tracingMiddleware } from "./middleware/tracing.js"; @@ -165,8 +166,19 @@ app.onError((error, c) => { if (error instanceof HTTPException) { const status = error.status; - - if (status >= 500) { + const upstreamCause = getUpstreamErrorCause(error.cause); + + if (upstreamCause) { + // Expected upstream/gateway provider error (e.g. a provider 429) + // forwarded from an internal chat-completions call. It is already + // recorded in the log table with its unifiedFinishReason, so surface + // it as a warning rather than an ERROR-level log. + logger.warn("Provider error", { + status, + message: error.message, + unifiedFinishReason: upstreamCause.unifiedFinishReason, + }); + } else if (status >= 500) { logger.error("HTTP 500 exception", error); } else { logger.warn("HTTP client error", { status, message: error.message }); diff --git a/apps/gateway/src/images/images.ts b/apps/gateway/src/images/images.ts index 8d24856afb..1e33637802 100644 --- a/apps/gateway/src/images/images.ts +++ b/apps/gateway/src/images/images.ts @@ -2,12 +2,14 @@ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; import { HTTPException } from "hono/http-exception"; import { app } from "@/app.js"; +import { upstreamErrorCause } from "@/lib/error-response.js"; import { parseDataUrl, processImageUrl } from "@llmgateway/actions"; import { logger, toError } from "@llmgateway/logger"; import type { ServerTypes } from "@/vars.js"; import type { Context } from "hono"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; const imageGenerationsRequestSchema = z.object({ prompt: z.string().min(1).openapi({ @@ -338,15 +340,23 @@ async function forwardToChatCompletions( }); const errorData = await response.text(); let errorMessage = `Image generation failed with status ${response.status}`; + let upstreamFinishReason: string | undefined; try { const parsed = JSON.parse(errorData); errorMessage = parsed?.error?.message ?? parsed?.message ?? errorMessage; + const errorType = parsed?.error?.type ?? parsed?.error?.code; + if (errorType === "upstream_error" || errorType === "gateway_error") { + upstreamFinishReason = errorType; + } } catch { // use default message } - throw new HTTPException(response.status as any, { + throw new HTTPException(response.status as ContentfulStatusCode, { message: errorMessage, + ...(upstreamFinishReason + ? { cause: upstreamErrorCause(upstreamFinishReason) } + : {}), }); } diff --git a/apps/gateway/src/lib/error-response.spec.ts b/apps/gateway/src/lib/error-response.spec.ts index b65d8286d7..62f7aacaae 100644 --- a/apps/gateway/src/lib/error-response.spec.ts +++ b/apps/gateway/src/lib/error-response.spec.ts @@ -5,6 +5,8 @@ import { buildOpenAIErrorBody, getAnthropicErrorType, getOpenAIErrorMeta, + getUpstreamErrorCause, + upstreamErrorCause, } from "./error-response.js"; describe("error-response", () => { @@ -70,4 +72,27 @@ describe("error-response", () => { expect(getAnthropicErrorType(429)).toBe("rate_limit_error"); expect(getAnthropicErrorType(500)).toBe("api_error"); }); + + test("round-trips the upstream error cause marker", () => { + const cause = upstreamErrorCause("upstream_error"); + expect(cause).toEqual({ + upstreamError: true, + unifiedFinishReason: "upstream_error", + }); + expect(getUpstreamErrorCause(cause)).toBe(cause); + expect(getUpstreamErrorCause(upstreamErrorCause("gateway_error"))).toEqual({ + upstreamError: true, + unifiedFinishReason: "gateway_error", + }); + }); + + test("rejects non-marker causes", () => { + expect(getUpstreamErrorCause(undefined)).toBeNull(); + expect(getUpstreamErrorCause(null)).toBeNull(); + expect(getUpstreamErrorCause(new Error("boom"))).toBeNull(); + expect(getUpstreamErrorCause({ upstreamError: true })).toBeNull(); + expect( + getUpstreamErrorCause({ unifiedFinishReason: "upstream_error" }), + ).toBeNull(); + }); }); diff --git a/apps/gateway/src/lib/error-response.ts b/apps/gateway/src/lib/error-response.ts index e26c7c6eb7..2f9831dc1a 100644 --- a/apps/gateway/src/lib/error-response.ts +++ b/apps/gateway/src/lib/error-response.ts @@ -128,6 +128,37 @@ export function buildOpenAIErrorBody(opts: { }; } +// Marker attached as the `cause` of an HTTPException when an internal +// chat-completions call surfaces an expected upstream/gateway provider error +// (e.g. a provider 429). These are already recorded in the log table with their +// unifiedFinishReason, so the global error handler should surface them as +// warnings rather than ERROR-level logs that trip error alerting. +export interface UpstreamErrorCause { + upstreamError: true; + unifiedFinishReason: string; +} + +export function upstreamErrorCause( + unifiedFinishReason: string, +): UpstreamErrorCause { + return { upstreamError: true, unifiedFinishReason }; +} + +export function getUpstreamErrorCause( + cause: unknown, +): UpstreamErrorCause | null { + if ( + cause && + typeof cause === "object" && + (cause as Partial).upstreamError === true && + typeof (cause as Partial).unifiedFinishReason === + "string" + ) { + return cause as UpstreamErrorCause; + } + return null; +} + export function buildAnthropicErrorBody(opts: { message: string; status: number;