Compare commits

...
Author SHA1 Message Date
404oops b8e938acda Merge remote-tracking branch 'origin/main' into fix/infron-opaque-upstream-errors 2026-10-22 22:51:14 +02:00
404oopsandClaude Opus 5.5 b08d3f926e fix(ai): treat a gateway 4xx with no upstream error body as a route failure
Infron (a new-api gateway) answers "bad response status code N" with the
upstream's status when the upstream replied without a JSON error, e.g. a
model the upstream no longer serves. That 4xx was classified as the
caller's bad request, so the request skipped fallback, the dead route
stayed in rotation, and the caller got upstream_bad_request. It now counts
as an upstream failure: fallback runs, the route is marked unhealthy, and
an exhausted chain reports upstream_provider_unavailable. A gateway error
that carries the vendor's own message still stays request-level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:39:27 +02:00
2 changed files with 63 additions and 0 deletions

No files matched your search

@@ -633,6 +633,40 @@ describe('ChatCompletionDriver exhausted-chain classification', () => {
expect(err.message).toBe('unsupported parameter: top_k');
});
it('maps a gateway 4xx with no upstream error body to 400 upstream_provider_unavailable', async () => {
const err = await errorFor({
status: 400,
message: '400 bad response status code 400',
error: {
type: 'upstream_error',
message: 'bad response status code 400',
code: 400,
},
});
expect(err).toMatchObject({
statusCode: 400,
legacyCode: 'upstream_provider_unavailable',
});
expect(err.message).toBe('AI provider unavailable');
});
it('keeps a gateway 4xx that carries the vendor message as the caller’s upstream_bad_request', async () => {
const err = await errorFor({
status: 400,
message:
'400 bad response status code 400, message: max_tokens too large, body: {}',
error: {
type: 'upstream_error',
message:
'bad response status code 400, message: max_tokens too large, body: {}',
},
});
expect(err).toMatchObject({
statusCode: 400,
legacyCode: 'upstream_bad_request',
});
});
it('maps a completion with no choices to a 400, not an internal 500', async () => {
vi.spyOn(FakeChatProvider.prototype, 'complete').mockImplementation(
() =>
@@ -809,6 +843,26 @@ describe('ChatCompletionDriver cross-provider fallback', () => {
expect(line).toContain('first 422');
});
it('falls back past a gateway 4xx with no upstream error body and marks the route unhealthy', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
vi.spyOn(AzureChatProvider.prototype, 'complete').mockRejectedValue(
Object.assign(new Error('400 bad response status code 400'), {
status: 400,
error: { type: 'upstream_error' },
}),
);
vi.spyOn(OpenAiChatProvider.prototype, 'complete').mockResolvedValue({
message: { role: 'assistant', content: 'from the fallback' },
usage: { prompt_tokens: 1, completion_tokens: 1 },
} as never);
const result = (await completeShared()) as {
message: { content: string };
};
expect(result.message.content).toBe('from the fallback');
expect(isRouteUnhealthy('azure-openai', SHARED_MODEL)).toBe(true);
});
it('surfaces the primary route-level 4xx after a non-strict fallback also fails', async () => {
vi.spyOn(AzureChatProvider.prototype, 'complete').mockRejectedValue(
Object.assign(new Error('unsupported parameter: frobnicate'), {
@@ -323,8 +323,17 @@ const isAuthFailure = (a: ProviderAttempt) =>
a.status === 403 ||
/unauthorized|forbidden|invalid api key/i.test(a.error));
/**
* A new-api gateway's (Infron's) wrapper for an upstream that answered without
* a JSON error body. It mirrors the upstream's status, so the 4xx says nothing
* about the caller's request; a real vendor rejection carries its own message.
*/
const isOpaqueGatewayFailure = (a: ProviderAttempt) =>
/bad response status code \d+(?:,\s*body:|$)/i.test(a.error);
const isUpstream5xx = (a: ProviderAttempt) =>
(a.status !== undefined && a.status >= 500) ||
isOpaqueGatewayFailure(a) ||
/provider returned error|internal server error|service unavailable|bad gateway/i.test(
a.error,
);