test(backend): cover structured error mapping

This commit is contained in:
Saravana Achu Mac 2026-05-05 11:15:02 -07:00
parent 4425aa7701
commit ff33a057ab
6 changed files with 179 additions and 10 deletions

View File

@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { BadRequestError, ConflictError, ForbiddenError, NotFoundError, UnauthorizedError } from '@bytelyst/errors';
import { createServiceApp } from '@bytelyst/fastify-core';
import { mapBlobFailure, mapExtractionFailure, mapLlmFailure } from './error-mapping.js';
async function buildErrorApp() {
const app = await createServiceApp({
name: 'error-mapping-test',
version: '0.1.0',
logger: false,
});
app.get('/validation', async () => {
throw new BadRequestError('Invalid request', { code: 'VALIDATION_ERROR', field: 'title' });
});
app.get('/auth', async () => {
throw new UnauthorizedError('Unauthorized', { code: 'AUTH_REQUIRED' });
});
app.get('/forbidden', async () => {
throw new ForbiddenError('Forbidden', { code: 'FORBIDDEN' });
});
app.get('/not-found', async () => {
throw new NotFoundError('Missing resource', { code: 'NOT_FOUND' });
});
app.get('/conflict', async () => {
throw new ConflictError('Duplicate idempotency key', { code: 'CONFLICT' });
});
app.get('/extraction-failure', async () => {
throw mapExtractionFailure(new Error('HTTP 503 Service Unavailable'));
});
app.get('/llm-timeout', async () => {
throw mapLlmFailure(new Error('LLM request timed out'));
});
app.get('/blob-failure', async () => {
throw mapBlobFailure(new Error('SAS token request failed'));
});
await app.ready();
return app;
}
describe('structured error mapping', () => {
it.each([
['/validation', 400, 'Invalid request', 'VALIDATION_ERROR'],
['/auth', 401, 'Unauthorized', 'AUTH_REQUIRED'],
['/forbidden', 403, 'Forbidden', 'FORBIDDEN'],
['/not-found', 404, 'Missing resource', 'NOT_FOUND'],
['/conflict', 409, 'Duplicate idempotency key', 'CONFLICT'],
['/extraction-failure', 502, 'Extraction service failed', 'EXTRACTION_SERVICE_FAILURE'],
['/llm-timeout', 504, 'LLM request timed out', 'LLM_TIMEOUT'],
['/blob-failure', 502, 'Blob service failed', 'BLOB_SERVICE_FAILURE'],
])('maps %s to a stable error response', async (url, statusCode, message, code) => {
const app = await buildErrorApp();
try {
const res = await app.inject({
method: 'GET',
url,
headers: { 'x-request-id': 'req-errors-1' },
});
expect(res.statusCode).toBe(statusCode);
expect(res.json()).toMatchObject({
error: message,
requestId: 'req-errors-1',
details: { code },
});
} finally {
await app.close();
}
});
});

View File

@ -0,0 +1,54 @@
import { ServiceError } from '@bytelyst/errors';
type DependencyKind = 'extraction-service' | 'llm' | 'blob';
function causeMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function dependencyError(
dependency: DependencyKind,
statusCode: number,
message: string,
code: string,
error: unknown,
): ServiceError {
return new ServiceError(statusCode, message, {
code,
dependency,
cause: causeMessage(error),
});
}
export function mapExtractionFailure(error: unknown): ServiceError {
return dependencyError(
'extraction-service',
502,
'Extraction service failed',
'EXTRACTION_SERVICE_FAILURE',
error,
);
}
export function mapLlmFailure(error: unknown): ServiceError {
const message = causeMessage(error).toLowerCase();
const timedOut = message.includes('timed out') || message.includes('timeout');
return dependencyError(
'llm',
timedOut ? 504 : 502,
timedOut ? 'LLM request timed out' : 'LLM request failed',
timedOut ? 'LLM_TIMEOUT' : 'LLM_FAILURE',
error,
);
}
export function mapBlobFailure(error: unknown): ServiceError {
return dependencyError(
'blob',
502,
'Blob service failed',
'BLOB_SERVICE_FAILURE',
error,
);
}

View File

@ -19,4 +19,20 @@ describe('extraction client request propagation', () => {
'x-request-id': 'req-propagated',
});
});
it('maps extraction-service failures to a structured dependency error', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify({ error: 'down' }), { status: 503, statusText: 'Service Unavailable' })),
);
await expect(extractFromText('hello', 'summarization')).rejects.toMatchObject({
statusCode: 502,
message: 'Extraction service failed',
details: {
code: 'EXTRACTION_SERVICE_FAILURE',
dependency: 'extraction-service',
},
});
});
});

View File

@ -1,4 +1,6 @@
import { ServiceError } from '@bytelyst/errors';
import { config } from './config.js';
import { mapExtractionFailure } from './error-mapping.js';
import { requestIdHeaders } from './request-context.js';
export interface ExtractionResult {
@ -14,15 +16,22 @@ export async function extractFromText(
): Promise<ExtractionResult> {
const url = `${config.EXTRACTION_SERVICE_URL}/api/extract`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...requestIdHeaders(options.requestId) },
body: JSON.stringify({ text, task: taskType }),
});
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...requestIdHeaders(options.requestId) },
body: JSON.stringify({ text, task: taskType }),
});
if (!res.ok) {
throw new Error(`Extraction service error: ${res.status} ${res.statusText}`);
if (!res.ok) {
throw mapExtractionFailure(new Error(`HTTP ${res.status} ${res.statusText}`));
}
return res.json() as Promise<ExtractionResult>;
} catch (error) {
if (error instanceof ServiceError) {
throw error;
}
throw mapExtractionFailure(error);
}
return res.json() as Promise<ExtractionResult>;
}

View File

@ -182,6 +182,20 @@ describe('executePrompt', () => {
expect(mockChatCompletion).toHaveBeenCalledTimes(1);
});
it('maps LLM timeouts to structured dependency errors', async () => {
mockChatCompletion.mockRejectedValue(new Error('LLM request timed out'));
await expect(executePrompt(makeTemplate(), makeInput(), 'body')).rejects.toMatchObject({
statusCode: 504,
message: 'LLM request timed out',
details: {
code: 'LLM_TIMEOUT',
dependency: 'llm',
},
});
expect(mockChatCompletion).toHaveBeenCalledTimes(1);
});
it('uses custom model from template if provided', async () => {
const tpl = makeTemplate({ model: 'gpt-4-turbo' });
await executePrompt(tpl, makeInput(), 'body');

View File

@ -4,6 +4,7 @@
import { llm } from '../../lib/llm.js';
import { config } from '../../lib/config.js';
import { mapLlmFailure } from '../../lib/error-mapping.js';
import { trackEvent } from '../../lib/telemetry.js';
import { buildNoteLettWakeUp } from '../palace/wakeup.js';
import { PRODUCT_ID } from '../../lib/product-config.js';
@ -141,5 +142,9 @@ export async function executePrompt(
}
}
throw lastError instanceof Error ? lastError : new Error('LLM call failed after retries');
if (lastError instanceof Error && lastError.message.toLowerCase().includes('timed out')) {
throw mapLlmFailure(lastError);
}
throw lastError instanceof Error ? lastError : mapLlmFailure(lastError);
}