test(backend): cover structured error mapping
This commit is contained in:
parent
4425aa7701
commit
ff33a057ab
71
backend/src/lib/error-mapping.test.ts
Normal file
71
backend/src/lib/error-mapping.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
54
backend/src/lib/error-mapping.ts
Normal file
54
backend/src/lib/error-mapping.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user