From 2f689002df033c08650210cdb5867bbd23ba6209 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Sat, 10 Oct 2026 17:36:03 -0400 Subject: [PATCH] fix(ai): map BytePlus video and Cloudflare image upstream errors at the driver boundary --- .../controllers/drivers/DriverController.ts | 3 +- .../CloudflareImageProvider.test.ts | 57 +++++++++----- .../cloudflare/CloudflareImageProvider.ts | 5 +- .../byteplus/BytePlusVideoProvider.test.ts | 75 +++++++++++++++++-- .../byteplus/BytePlusVideoProvider.ts | 27 ++++--- 5 files changed, 131 insertions(+), 36 deletions(-) diff --git a/src/backend/controllers/drivers/DriverController.ts b/src/backend/controllers/drivers/DriverController.ts index c3e28cf43..fe3cc674b 100644 --- a/src/backend/controllers/drivers/DriverController.ts +++ b/src/backend/controllers/drivers/DriverController.ts @@ -111,7 +111,8 @@ const extractUpstreamStatus = (e: { return undefined; }; -const translateProviderError = (err: unknown): unknown => { +/** Maps a provider failure onto the error the `/drivers/call` caller sees. */ +export const translateProviderError = (err: unknown): unknown => { if (isHttpError(err)) return err; if (!err || typeof err !== 'object') return err; const e = err as { diff --git a/src/backend/drivers/ai-image/providers/cloudflare/CloudflareImageProvider.test.ts b/src/backend/drivers/ai-image/providers/cloudflare/CloudflareImageProvider.test.ts index 139670dc0..17347b2fb 100644 --- a/src/backend/drivers/ai-image/providers/cloudflare/CloudflareImageProvider.test.ts +++ b/src/backend/drivers/ai-image/providers/cloudflare/CloudflareImageProvider.test.ts @@ -45,6 +45,7 @@ import { setupTestServer } from '../../../../testUtil.js'; import { withTestActor } from '../../../integrationTestUtil.js'; import { CLOUDFLARE_IMAGE_GENERATION_MODELS } from './models.js'; import { CloudflareImageProvider } from './CloudflareImageProvider.js'; +import { translateProviderError } from '../../../../controllers/drivers/DriverController.js'; // Keep URL downloads offline; other input parsing stays real. const { fetchImageBytesMock } = vi.hoisted(() => ({ @@ -372,24 +373,46 @@ describe('CloudflareImageProvider.generate output extraction', () => { ).rejects.toMatchObject({ statusCode: 400 }); }); - it('throws 400 with the upstream error message on a non-2xx response', async () => { - const provider = makeProvider(); - fetchSpy.mockResolvedValueOnce( - new Response(JSON.stringify({ error: 'boom' }), { - status: 500, - headers: { 'content-type': 'application/json' }, - }), - ); + it.each([ + [429, 429, 'upstream_rate_limited'], + [401, 500, 'upstream_auth_failed'], + [500, 400, 'upstream_provider_unavailable'], + [400, 400, 'upstream_bad_request'], + ])( + 'maps an upstream %i to %i %s at the driver boundary', + async (upstreamStatus, statusCode, legacyCode) => { + const provider = makeProvider(); + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: false, + errors: [{ message: 'boom' }], + }), + { + status: upstreamStatus, + headers: { 'content-type': 'application/json' }, + }, + ), + ); - await expect( - withTestActor(() => - provider.generate({ - model: '@cf/black-forest-labs/flux-1-schnell', - prompt: 'hi', - } as never), - ), - ).rejects.toMatchObject({ statusCode: 400 }); - }); + let callerError: unknown; + try { + await withTestActor(() => + provider.generate({ + model: '@cf/black-forest-labs/flux-1-schnell', + prompt: 'hi', + } as never), + ); + } catch (e) { + callerError = translateProviderError(e); + } + expect(callerError).toMatchObject({ + statusCode, + legacyCode, + fields: { upstreamStatus }, + }); + }, + ); it('throws 400 when JSON response carries no usable image string', async () => { const provider = makeProvider(); diff --git a/src/backend/drivers/ai-image/providers/cloudflare/CloudflareImageProvider.ts b/src/backend/drivers/ai-image/providers/cloudflare/CloudflareImageProvider.ts index 7b0c3662c..75fa060c9 100644 --- a/src/backend/drivers/ai-image/providers/cloudflare/CloudflareImageProvider.ts +++ b/src/backend/drivers/ai-image/providers/cloudflare/CloudflareImageProvider.ts @@ -489,7 +489,10 @@ export class CloudflareImageProvider implements IImageProvider { const message = this.#extractErrorMessage(payload) || `Cloudflare image generation failed with status ${response.status}`; - throw new HttpError(400, message, { legacyCode: 'unknown_error' }); + // The driver boundary maps the upstream status to the caller's. + throw Object.assign(new Error(message), { + status: response.status, + }); } if (typeof payload === 'object' && payload !== null) { diff --git a/src/backend/drivers/ai-video/providers/byteplus/BytePlusVideoProvider.test.ts b/src/backend/drivers/ai-video/providers/byteplus/BytePlusVideoProvider.test.ts index 33aa3fe06..0b32ce8b2 100644 --- a/src/backend/drivers/ai-video/providers/byteplus/BytePlusVideoProvider.test.ts +++ b/src/backend/drivers/ai-video/providers/byteplus/BytePlusVideoProvider.test.ts @@ -46,6 +46,7 @@ import { withTestActor } from '../../../integrationTestUtil.js'; import { BYTEPLUS_VIDEO_GENERATION_MODELS } from './models.js'; import { VIDEO_POLL_WINDOW_MS } from '../polling.js'; import { BytePlusVideoProvider } from './BytePlusVideoProvider.js'; +import { translateProviderError } from '../../../../controllers/drivers/DriverController.js'; // -- Test harness ---------------------------------------------------- @@ -456,13 +457,77 @@ describe('BytePlusVideoProvider.generate polling and outcomes', () => { expect(incrementUsageSpy).not.toHaveBeenCalled(); }); - it('maps upstream 5xx on create to a 502', async () => { + /** The failure as `/drivers/call` hands it to the caller. */ + const callerError = async (): Promise => { + try { + await withTestActor(() => + makeProvider().generate({ prompt: 'hi' }), + ); + } catch (e) { + return translateProviderError(e); + } + throw new Error('expected generate to reject'); + }; + + const arkError = (code: string, message: string, status: number) => + jsonResponse({ error: { code, message } }, status); + + it.each([ + [ + 429, + 'RateLimitExceeded.EndpointRPMExceeded', + 429, + 'upstream_rate_limited', + ], + [401, 'AuthenticationError', 500, 'upstream_auth_failed'], + [403, 'AccessDenied', 500, 'upstream_auth_failed'], + [500, 'InternalServiceError', 400, 'upstream_provider_unavailable'], + [400, 'InvalidParameter', 400, 'upstream_bad_request'], + ])( + 'maps an upstream %i (%s) on create to %i %s', + async (upstreamStatus, upstreamCode, statusCode, legacyCode) => { + fetchSpy.mockResolvedValueOnce( + arkError(upstreamCode, 'nope', upstreamStatus), + ); + expect(await callerError()).toMatchObject({ + statusCode, + legacyCode, + fields: { upstreamStatus, upstreamCode }, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(incrementUsageSpy).not.toHaveBeenCalled(); + }, + ); + + it.each([429, 503])( + 'retries a status poll that returns %i and finishes the job', + async (status) => { + mockTaskFlow(); + fetchSpy.mockResolvedValueOnce( + arkError('Busy', 'try later', status), + ); + fetchSpy.mockResolvedValueOnce(jsonResponse(succeededTask())); + + const result = await withTestActor(() => + makeProvider().generate({ prompt: 'hi' }), + ); + + expect(result).toBe('https://ark.example/video.mp4'); + expect(fetchSpy).toHaveBeenCalledTimes(3); + expect(incrementUsageSpy).toHaveBeenCalledTimes(1); + }, + ); + + it('does not retry a status poll rejected for auth', async () => { + mockTaskFlow(); fetchSpy.mockResolvedValueOnce( - jsonResponse({ error: { message: 'boom' } }, 500), + arkError('AuthenticationError', 'bad key', 401), ); - await expect( - withTestActor(() => makeProvider().generate({ prompt: 'hi' })), - ).rejects.toMatchObject({ statusCode: 502 }); + expect(await callerError()).toMatchObject({ + statusCode: 500, + legacyCode: 'upstream_auth_failed', + }); + expect(fetchSpy).toHaveBeenCalledTimes(2); }); }); diff --git a/src/backend/drivers/ai-video/providers/byteplus/BytePlusVideoProvider.ts b/src/backend/drivers/ai-video/providers/byteplus/BytePlusVideoProvider.ts index 35143216f..f8c1acd9a 100644 --- a/src/backend/drivers/ai-video/providers/byteplus/BytePlusVideoProvider.ts +++ b/src/backend/drivers/ai-video/providers/byteplus/BytePlusVideoProvider.ts @@ -357,19 +357,22 @@ export class BytePlusVideoProvider extends VideoProvider { }, ...(opts.body ? { body: JSON.stringify(opts.body) } : {}), }); - const payload = (await response.json().catch(() => ({}))) as Record< - string, - unknown - >; + let payload: { error?: { code?: string; message?: string } } = {}; + try { + payload = (await response.json()) ?? {}; + } catch { + // Non-JSON body; the status alone decides. + } if (!response.ok) { - const message = - ((payload.error as Record) - ?.message as string) ?? - `BytePlus video API error (status ${response.status})`; - throw new HttpError(response.status >= 500 ? 502 : 400, message, { - legacyCode: 'upstream_failed', - fields: { provider: 'byteplus' }, - }); + // Carry the upstream status so the poller can retry 429/5xx and + // the driver boundary maps it like any SDK error. + throw Object.assign( + new Error( + payload.error?.message ?? + `BytePlus video API error (status ${response.status})`, + ), + { status: response.status, code: payload.error?.code }, + ); } return payload; }