Skip to content

Commit 74feaa5

Browse files
refactor: better normalized llm http response handling (#106)
1 parent 74fb5d3 commit 74feaa5

2 files changed

Lines changed: 112 additions & 3 deletions

File tree

lib/ResilientLLM.ts

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -825,9 +825,7 @@ class ResilientLLM {
825825

826826
console.log("Response Headers:", response?.headers);
827827

828-
const data = await response?.json() as Record<string, unknown>;
829-
830-
const result: HttpResult = { data, statusCode: response?.status };
828+
const result = await ResilientLLM._readProviderJsonResponse(response);
831829

832830
const httpDurationMs = Date.now() - httpStartTime;
833831
console.log(`Request to ${apiUrl} completed in ${httpDurationMs} ms`);
@@ -853,6 +851,80 @@ class ResilientLLM {
853851
}
854852
}
855853

854+
/**
855+
* Reads a provider response as JSON when possible, otherwise returns a synthetic JSON error payload.
856+
*/
857+
static async _readProviderJsonResponse(response: Response): Promise<HttpResult> {
858+
const statusCode = response?.status ?? 0;
859+
const contentType = response?.headers?.get?.('content-type') || '';
860+
const normalizedContentType = contentType.toLowerCase();
861+
const isJson = normalizedContentType.includes('application/json') || normalizedContentType.includes('+json');
862+
const responseLike = response as Response & {
863+
text?: () => Promise<string>;
864+
json?: () => Promise<unknown>;
865+
};
866+
867+
// Prefer text() when available to keep a single, controlled body parse path.
868+
if (typeof responseLike.text === 'function') {
869+
const raw = await responseLike.text().catch(() => '');
870+
const snippet = (raw || '').slice(0, 2000);
871+
872+
if (isJson) {
873+
try {
874+
const parsed = raw ? (JSON.parse(raw) as Record<string, unknown>) : ({} as Record<string, unknown>);
875+
return { data: parsed, statusCode };
876+
} catch {
877+
return {
878+
statusCode,
879+
data: {
880+
error: {
881+
message: `Invalid JSON from provider (HTTP ${statusCode}). ${snippet ? `Body: ${snippet}` : 'Empty body.'}`,
882+
},
883+
},
884+
};
885+
}
886+
}
887+
888+
return {
889+
statusCode,
890+
data: {
891+
error: {
892+
message: `Expected JSON from provider but received "${contentType || 'unknown'}" (HTTP ${statusCode}). ${snippet ? `Body: ${snippet}` : 'Empty body.'}`,
893+
},
894+
},
895+
};
896+
}
897+
898+
// Compatibility fallback for partial/mock responses that only expose json().
899+
if (typeof responseLike.json === 'function') {
900+
try {
901+
const parsed = await responseLike.json();
902+
return {
903+
statusCode,
904+
data: (parsed ?? {}) as Record<string, unknown>,
905+
};
906+
} catch {
907+
return {
908+
statusCode,
909+
data: {
910+
error: {
911+
message: `Invalid JSON from provider (HTTP ${statusCode}). Empty body.`,
912+
},
913+
},
914+
};
915+
}
916+
}
917+
918+
return {
919+
statusCode,
920+
data: {
921+
error: {
922+
message: `Expected JSON from provider but received "${contentType || 'unknown'}" (HTTP ${statusCode}). Empty body.`,
923+
},
924+
},
925+
};
926+
}
927+
856928
static _captureHttpMetadata(
857929
metadata: OperationMetadata,
858930
apiUrl: string,

test/resilient-llm.unit.test.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,43 @@ describe('ResilientLLM Unit Tests', () => {
4848
sinon.restore();
4949
});
5050

51+
describe('Provider response JSON reader', () => {
52+
it('returns parsed data when mock response only implements json()', async () => {
53+
const result = await ResilientLLM._readProviderJsonResponse({
54+
status: 200,
55+
headers: { get: () => null },
56+
json: async () => ({ choices: [{ message: { content: 'ok' } }] }),
57+
});
58+
59+
expect(result.statusCode).to.equal(200);
60+
expect(result.data).to.deep.equal({ choices: [{ message: { content: 'ok' } }] });
61+
});
62+
63+
it('returns synthetic error payload when provider responds with non-json text body', async () => {
64+
const result = await ResilientLLM._readProviderJsonResponse({
65+
status: 502,
66+
headers: { get: () => 'text/html; charset=utf-8' },
67+
text: async () => '<html>Bad Gateway</html>',
68+
});
69+
70+
expect(result.statusCode).to.equal(502);
71+
expect(result.data?.error?.message).to.include('Expected JSON from provider');
72+
expect(result.data?.error?.message).to.include('text/html');
73+
});
74+
75+
it('returns synthetic error payload when provider sends invalid json body', async () => {
76+
const result = await ResilientLLM._readProviderJsonResponse({
77+
status: 500,
78+
headers: { get: () => 'application/json' },
79+
text: async () => '{"broken": ',
80+
});
81+
82+
expect(result.statusCode).to.equal(500);
83+
expect(result.data?.error?.message).to.include('Invalid JSON from provider');
84+
expect(result.data?.error?.message).to.include('Body: {"broken": ');
85+
});
86+
});
87+
5188
describe('Happy Path Tests', () => {
5289
it('should successfully complete a chat request and return parsed response', async () => {
5390
// Arrange

0 commit comments

Comments
 (0)