diff --git a/backend/src/lib/error-mapping.test.ts b/backend/src/lib/error-mapping.test.ts new file mode 100644 index 0000000..e7ed3fb --- /dev/null +++ b/backend/src/lib/error-mapping.test.ts @@ -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(); + } + }); +}); diff --git a/backend/src/lib/error-mapping.ts b/backend/src/lib/error-mapping.ts new file mode 100644 index 0000000..8a2a71e --- /dev/null +++ b/backend/src/lib/error-mapping.ts @@ -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, + ); +} diff --git a/backend/src/lib/extraction-client.test.ts b/backend/src/lib/extraction-client.test.ts index 58e3780..ad45486 100644 --- a/backend/src/lib/extraction-client.test.ts +++ b/backend/src/lib/extraction-client.test.ts @@ -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', + }, + }); + }); }); diff --git a/backend/src/lib/extraction-client.ts b/backend/src/lib/extraction-client.ts index f755525..c942703 100644 --- a/backend/src/lib/extraction-client.ts +++ b/backend/src/lib/extraction-client.ts @@ -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 { 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; + } catch (error) { + if (error instanceof ServiceError) { + throw error; + } + throw mapExtractionFailure(error); } - - return res.json() as Promise; } diff --git a/backend/src/modules/note-prompts/runner.test.ts b/backend/src/modules/note-prompts/runner.test.ts index 93c6fc5..7496395 100644 --- a/backend/src/modules/note-prompts/runner.test.ts +++ b/backend/src/modules/note-prompts/runner.test.ts @@ -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'); diff --git a/backend/src/modules/note-prompts/runner.ts b/backend/src/modules/note-prompts/runner.ts index 8c92387..de2cd24 100644 --- a/backend/src/modules/note-prompts/runner.ts +++ b/backend/src/modules/note-prompts/runner.ts @@ -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); }