Skip to content

Commit c92b3b8

Browse files
feat: capture all provider response headers and return x-request-id to caller
- Extract all upstream provider response headers in handleProviderError - Store providerResponseHeaders on error routingContext - Add providerResponseHeaders to RetryAttemptRecord so they persist in retryHistory - Record intermediate provider failures via saveIntermediateError during failover retry - Return x-request-id response header on both success (handleResponse) and error paths - Update all route handlers (chat, messages, embeddings, images, speech, transcriptions, gemini, responses)
1 parent 3c82fc1 commit c92b3b8

12 files changed

Lines changed: 195 additions & 43 deletions

File tree

packages/backend/src/routes/inference/__tests__/images-routes.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type FakeRequest = {
2929
};
3030

3131
type FakeReply = {
32+
header: (name: string, value: string) => FakeReply;
3233
code: (statusCode: number) => FakeReply;
3334
send: (payload: unknown) => unknown;
3435
};
@@ -100,6 +101,7 @@ describe('Images route telemetry', () => {
100101

101102
const replyState: { statusCode?: number; payload?: unknown } = {};
102103
const reply: FakeReply = {
104+
header: vi.fn(() => reply),
103105
code: vi.fn((statusCode: number) => {
104106
replyState.statusCode = statusCode;
105107
return reply;
@@ -214,6 +216,7 @@ describe('Images route telemetry', () => {
214216

215217
const replyState: { statusCode?: number; payload?: unknown } = {};
216218
const reply: FakeReply = {
219+
header: vi.fn(() => reply),
217220
code: vi.fn((statusCode: number) => {
218221
replyState.statusCode = statusCode;
219222
return reply;

packages/backend/src/routes/inference/chat.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export async function registerChatRoute(
137137
DebugManager.getInstance().flush(requestId);
138138

139139
logger.error('Error processing OpenAI request', e);
140+
reply.header('x-request-id', requestId);
140141
const statusCode = e.routingContext?.statusCode || 500;
141142
const errorType =
142143
statusCode === 401

packages/backend/src/routes/inference/embeddings.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export async function registerEmbeddingsRoute(
9494
DebugManager.getInstance().addTransformedResponse(requestId, formattedResponse);
9595
DebugManager.getInstance().flush(requestId);
9696

97-
return reply.send(formattedResponse);
97+
return reply.header('x-request-id', requestId).send(formattedResponse);
9898
} catch (e: any) {
9999
usageRecord.responseStatus = 'error';
100100
usageRecord.durationMs = Date.now() - startTime;
@@ -111,9 +111,12 @@ export async function registerEmbeddingsRoute(
111111
DebugManager.getInstance().flush(requestId);
112112
logger.error('Error processing embeddings request', e);
113113

114-
return reply.code(e.routingContext?.statusCode || 500).send({
115-
error: { message: e.message, type: 'api_error' },
116-
});
114+
return reply
115+
.header('x-request-id', requestId)
116+
.code(e.routingContext?.statusCode || 500)
117+
.send({
118+
error: { message: e.message, type: 'api_error' },
119+
});
117120
}
118121
});
119122
}

packages/backend/src/routes/inference/gemini.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export async function registerGeminiRoute(
148148
logger.error('Error processing Gemini request', e);
149149
const statusCode = e.routingContext?.statusCode || 500;
150150
const errorReason = e.routingContext?.code;
151+
reply.header('x-request-id', requestId);
151152
const status =
152153
statusCode === 401
153154
? 'UNAUTHENTICATED'

packages/backend/src/routes/inference/images.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export async function registerImagesRoute(
117117
delete (unifiedResponse as any).plexus;
118118
}
119119

120-
return reply.send(unifiedResponse);
120+
return reply.header('x-request-id', requestId).send(unifiedResponse);
121121
} catch (e: any) {
122122
usageRecord.responseStatus = 'error';
123123
usageRecord.durationMs = Date.now() - startTime;
@@ -134,9 +134,12 @@ export async function registerImagesRoute(
134134
DebugManager.getInstance().flush(requestId);
135135
logger.error('Error processing image generation request', e);
136136

137-
return reply.code(e.routingContext?.statusCode || 500).send({
138-
error: { message: e.message, type: 'api_error' },
139-
});
137+
return reply
138+
.header('x-request-id', requestId)
139+
.code(e.routingContext?.statusCode || 500)
140+
.send({
141+
error: { message: e.message, type: 'api_error' },
142+
});
140143
}
141144
});
142145

@@ -285,7 +288,7 @@ export async function registerImagesRoute(
285288
delete (unifiedResponse as any).plexus;
286289
}
287290

288-
return reply.send(unifiedResponse);
291+
return reply.header('x-request-id', requestId).send(unifiedResponse);
289292
} catch (e: any) {
290293
usageRecord.responseStatus = 'error';
291294
usageRecord.durationMs = Date.now() - startTime;
@@ -302,9 +305,12 @@ export async function registerImagesRoute(
302305
DebugManager.getInstance().flush(requestId);
303306
logger.error('Error processing image edit request', e);
304307

305-
return reply.code(e.routingContext?.statusCode || 500).send({
306-
error: { message: e.message, type: 'api_error' },
307-
});
308+
return reply
309+
.header('x-request-id', requestId)
310+
.code(e.routingContext?.statusCode || 500)
311+
.send({
312+
error: { message: e.message, type: 'api_error' },
313+
});
308314
}
309315
});
310316
}

packages/backend/src/routes/inference/messages.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -144,14 +144,17 @@ export async function registerMessagesRoute(
144144
? 'invalid_request_error'
145145
: 'api_error';
146146
const errorCode = e.routingContext?.code;
147-
return reply.code(statusCode).send({
148-
type: 'error',
149-
error: {
150-
type: errorType,
151-
message: e.message,
152-
...(errorCode && { code: errorCode }),
153-
},
154-
});
147+
return reply
148+
.header('x-request-id', requestId)
149+
.code(statusCode)
150+
.send({
151+
type: 'error',
152+
error: {
153+
type: errorType,
154+
message: e.message,
155+
...(errorCode && { code: errorCode }),
156+
},
157+
});
155158
}
156159
});
157160
}

packages/backend/src/routes/inference/responses.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -237,20 +237,23 @@ export async function registerResponsesRoute(
237237

238238
const statusCode = e.routingContext?.statusCode || 500;
239239
const errorCode = e.routingContext?.code;
240-
return reply.code(statusCode).send({
241-
error: {
242-
message: e.message || 'Internal server error',
243-
type: statusCode >= 500 ? 'server_error' : 'invalid_request_error',
244-
...(errorCode && { code: errorCode }),
245-
...(e.routingContext && {
246-
routing_context: {
247-
provider: e.routingContext.provider,
248-
target_model: e.routingContext.targetModel,
249-
target_api_type: e.routingContext.targetApiType,
250-
},
251-
}),
252-
},
253-
});
240+
return reply
241+
.header('x-request-id', requestId)
242+
.code(statusCode)
243+
.send({
244+
error: {
245+
message: e.message || 'Internal server error',
246+
type: statusCode >= 500 ? 'server_error' : 'invalid_request_error',
247+
...(errorCode && { code: errorCode }),
248+
...(e.routingContext && {
249+
routing_context: {
250+
provider: e.routingContext.provider,
251+
target_model: e.routingContext.targetModel,
252+
target_api_type: e.routingContext.targetApiType,
253+
},
254+
}),
255+
},
256+
});
254257
}
255258
};
256259

packages/backend/src/routes/inference/speech.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,10 @@ export async function registerSpeechRoute(
134134

135135
if (unifiedResponse.stream) {
136136
usageRecord.isStreamed = true;
137-
return reply.send(unifiedResponse.stream);
137+
return reply.header('x-request-id', requestId).send(unifiedResponse.stream);
138138
}
139139

140-
return reply.send(unifiedResponse.audio);
140+
return reply.header('x-request-id', requestId).send(unifiedResponse.audio);
141141
} catch (e: any) {
142142
usageRecord.responseStatus = 'error';
143143
usageRecord.durationMs = Date.now() - startTime;
@@ -154,9 +154,12 @@ export async function registerSpeechRoute(
154154
DebugManager.getInstance().flush(requestId);
155155
logger.error('Error processing speech request', e);
156156

157-
return reply.code(e.routingContext?.statusCode || 500).send({
158-
error: { message: e.message, type: 'api_error' },
159-
});
157+
return reply
158+
.header('x-request-id', requestId)
159+
.code(e.routingContext?.statusCode || 500)
160+
.send({
161+
error: { message: e.message, type: 'api_error' },
162+
});
160163
}
161164
});
162165
}

packages/backend/src/routes/inference/transcriptions.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export async function registerTranscriptionsRoute(
186186
reply.type('application/json');
187187
}
188188

189-
return reply.send(formattedResponse);
189+
return reply.header('x-request-id', requestId).send(formattedResponse);
190190
} catch (e: any) {
191191
usageRecord.responseStatus = 'error';
192192
usageRecord.durationMs = Date.now() - startTime;
@@ -203,9 +203,12 @@ export async function registerTranscriptionsRoute(
203203
DebugManager.getInstance().flush(requestId);
204204
logger.error('Error processing transcription request', e);
205205

206-
return reply.code(e.routingContext?.statusCode || 500).send({
207-
error: { message: e.message, type: 'api_error' },
208-
});
206+
return reply
207+
.header('x-request-id', requestId)
208+
.code(e.routingContext?.statusCode || 500)
209+
.send({
210+
error: { message: e.message, type: 'api_error' },
211+
});
209212
}
210213
});
211214
}

packages/backend/src/services/__tests__/dispatcher-failover.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,4 +560,84 @@ describe('Dispatcher Failover', () => {
560560
expect(meta?.attemptCount).toBe(1);
561561
expect(meta?.finalAttemptProvider).toBe('p2');
562562
});
563+
564+
test('provider error captures all upstream response headers in routing context', async () => {
565+
setConfigForTesting(makeConfig({ targetCount: 1 }));
566+
fetchMock.mockImplementation(
567+
async () =>
568+
new Response(JSON.stringify({ error: { message: 'boom' } }), {
569+
status: 500,
570+
headers: {
571+
'Content-Type': 'application/json',
572+
'x-request-id': 'provider-req-123',
573+
'X-Custom-Header': 'custom-value',
574+
},
575+
})
576+
);
577+
578+
const dispatcher = new Dispatcher();
579+
580+
try {
581+
await dispatcher.dispatch({ ...makeChatRequest(), requestId: 'req-headers-test' });
582+
throw new Error('expected dispatch to fail');
583+
} catch (error: any) {
584+
expect(error.routingContext?.providerResponseHeaders).toBeDefined();
585+
expect(error.routingContext?.providerResponseHeaders['x-request-id']).toBe('provider-req-123');
586+
expect(error.routingContext?.providerResponseHeaders['x-custom-header']).toBe('custom-value');
587+
expect(error.routingContext?.providerResponseHeaders['content-type']).toBe('application/json');
588+
}
589+
});
590+
591+
test('retry history includes provider response headers on failed attempts', async () => {
592+
setConfigForTesting(makeConfig({ targetCount: 2 }));
593+
fetchMock
594+
.mockImplementationOnce(
595+
async () =>
596+
new Response(JSON.stringify({ error: { message: 'first boom' } }), {
597+
status: 500,
598+
headers: { 'x-request-id': 'first-req-id' },
599+
})
600+
)
601+
.mockImplementationOnce(async () => successChatResponse('model-2'));
602+
603+
const dispatcher = new Dispatcher();
604+
const response = await dispatcher.dispatch({ ...makeChatRequest(), requestId: 'req-retry-hist' });
605+
const meta = (response as any).plexus;
606+
const retryHistory = JSON.parse(meta?.retryHistory || '[]');
607+
608+
expect(retryHistory).toHaveLength(2);
609+
expect(retryHistory[0]?.status).toBe('failed');
610+
expect(retryHistory[0]?.providerResponseHeaders?.['x-request-id']).toBe('first-req-id');
611+
expect(retryHistory[1]?.status).toBe('success');
612+
});
613+
614+
test('intermediate failures are saved during failover', async () => {
615+
setConfigForTesting(makeConfig({ targetCount: 2 }));
616+
fetchMock
617+
.mockImplementationOnce(async () => errorResponse(500, 'first failed'))
618+
.mockImplementationOnce(async () => successChatResponse('model-2'));
619+
620+
const saveErrorSpy = vi.fn();
621+
const dispatcher = new Dispatcher();
622+
dispatcher.setUsageStorage({
623+
saveError: saveErrorSpy,
624+
recordFailedAttempt: vi.fn(),
625+
recordSuccessfulAttempt: vi.fn(),
626+
} as any);
627+
628+
const response = await dispatcher.dispatch({
629+
...makeChatRequest(),
630+
requestId: 'req-intermediate',
631+
});
632+
const meta = (response as any).plexus;
633+
634+
expect(meta?.attemptCount).toBe(2);
635+
// One call for the intermediate failure, one in the route handler doesn't happen
636+
// here because dispatch succeeded overall.
637+
expect(saveErrorSpy).toHaveBeenCalledTimes(1);
638+
const [savedRequestId, savedError, savedDetails] = saveErrorSpy.mock.calls[0] as any[];
639+
expect(savedRequestId).toBe('req-intermediate');
640+
expect(savedError?.routingContext?.statusCode).toBe(500);
641+
expect(savedDetails?.apiType).toBe('chat');
642+
});
563643
});

0 commit comments

Comments
 (0)