fix(ai): map BytePlus video and Cloudflare image upstream errors at the driver boundary

This commit is contained in:
Daniel Salazar committed 2026-10-11 11:58:25 -04:00
1 parent 491729e060
commit 2f689002df
5 files changed
+131 -36

No files matched your search

@@ -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 {
@@ -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();
@@ -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) {
@@ -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<unknown> => {
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);
});
});
@@ -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<string, unknown>)
?.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;
}