diff --git a/src/internal/parse.ts b/src/internal/parse.ts index 39174399e..d7e01a02b 100644 --- a/src/internal/parse.ts +++ b/src/internal/parse.ts @@ -63,7 +63,16 @@ export async function defaultParseResponse( return undefined as T; } - const json = await response.json(); + const bodyText = await response.text(); + if (!bodyText) { + // Some servers respond with an empty body and a JSON content-type but + // without a `content-length: 0` header (e.g. over HTTP/2 or with chunked + // transfer encoding). Treat this the same as an explicit `content-length: 0` + // instead of letting `JSON.parse` throw an opaque `SyntaxError`. + return undefined as T; + } + + const json = JSON.parse(bodyText); return addRequestID(json as T, response); } diff --git a/tests/index.test.ts b/tests/index.test.ts index a5df13c0a..60a38347e 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -676,6 +676,56 @@ describe('default encoder', () => { }); }); +describe('response parsing', () => { + test('handles an empty JSON body without a content-length header', async () => { + // Some servers (e.g. over HTTP/2 or with chunked transfer encoding) return an + // empty body with a JSON content-type but omit the `content-length` header. + // `new Response('')` reproduces this: an empty body with no `content-length`. + // This previously threw `SyntaxError: Unexpected end of JSON input`. + const testFetch = async (): Promise => { + const response = new Response('', { headers: { 'Content-Type': 'application/json' } }); + expect(response.headers.get('content-length')).toBeNull(); + return response; + }; + + const client = new OpenAI({ apiKey: 'My API Key', adminAPIKey: 'My Admin API Key', fetch: testFetch }); + + expect(await client.request({ path: '/foo', method: 'get' })).toBeUndefined(); + }); + + test('handles an empty JSON body with an explicit content-length: 0 header', async () => { + const testFetch = async (): Promise => + new Response('', { headers: { 'Content-Type': 'application/json', 'Content-Length': '0' } }); + + const client = new OpenAI({ apiKey: 'My API Key', adminAPIKey: 'My Admin API Key', fetch: testFetch }); + + expect(await client.request({ path: '/foo', method: 'get' })).toBeUndefined(); + }); + + test('parses a non-empty JSON body', async () => { + const testFetch = async (): Promise => + new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } }); + + const client = new OpenAI({ apiKey: 'My API Key', adminAPIKey: 'My Admin API Key', fetch: testFetch }); + + expect(await client.request({ path: '/foo', method: 'get' })).toEqual({ a: 1 }); + }); + + test('still throws on a malformed non-empty JSON body', async () => { + const testFetch = async (): Promise => + new Response('{ not json', { headers: { 'Content-Type': 'application/json' } }); + + const client = new OpenAI({ + apiKey: 'My API Key', + adminAPIKey: 'My Admin API Key', + maxRetries: 0, + fetch: testFetch, + }); + + await expect(client.request({ path: '/foo', method: 'get' })).rejects.toThrow(); + }); +}); + describe('retries', () => { test('retry on timeout', async () => { let count = 0;