From 041022e828f4ab384fdf571bbb08e600b2b097f6 Mon Sep 17 00:00:00 2001 From: Matt Robenolt Date: Mon, 23 Mar 2026 12:37:49 -0700 Subject: [PATCH] Add UnknownError with full response context for non-JSON API errors When an intermediary (e.g. Cloudflare Workers egress proxy) returns a non-JSON response, capture HTTP status, headers, and body so the error is diagnosable in Sentry without guesswork. --- __tests__/index.test.ts | 38 ++++++++++++++++++------ src/index.ts | 66 ++++++++++++++++++++++++++++++++--------- 2 files changed, 81 insertions(+), 23 deletions(-) diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 76d86e1..001722a 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -1,5 +1,5 @@ import SqlString from 'sqlstring' -import { connect, format, hex, DatabaseError, type Cast } from '../dist/index' +import { connect, format, hex, DatabaseError, UnknownError, type Cast } from '../dist/index' import { fetch, MockAgent, setGlobalDispatcher } from 'undici' import packageJSON from '../package.json' @@ -125,7 +125,7 @@ describe('transaction', () => { }) mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(401, () => { numRequests++ - return mockError + return { error: mockError } }) mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, () => { numRequests++ @@ -139,7 +139,7 @@ describe('transaction', () => { }) } catch (err) { expect(numRequests).toEqual(4) - expect(err).toEqual(new DatabaseError('Unauthorized', 401, mockError)) + expect(err).toEqual(new DatabaseError(mockError.message, 401, mockError)) } }) }) @@ -382,19 +382,39 @@ describe('execute', () => { } }) - test('it properly returns network errors when not json', async () => { - const mockError = { - code: 'internal', - message: 'Internal Server Error' + test('it returns UnknownError for non-JSON error responses', async () => { + mockPool + .intercept({ path: EXECUTE_PATH, method: 'POST' }) + .reply(500, 'Bad Gateway', { headers: { 'content-type': 'text/html' } }) + + const connection = connect(config) + try { + await connection.execute('SELECT * from foo;') + throw new Error('Expected UnknownError') + } catch (err) { + expect(err).toBeInstanceOf(UnknownError) + expect(err).toBeInstanceOf(DatabaseError) + const upstream = err as InstanceType + expect(upstream.status).toEqual(500) + expect(upstream.context.body).toEqual('Bad Gateway') + expect(upstream.context.status).toEqual(500) + expect(upstream.message).toMatch(/Expected a JSON response/) } + }) - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(500, mockError) + test('it returns UnknownError for JSON without error field', async () => { + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(500, { code: 'internal', message: 'oops' }) const connection = connect(config) try { await connection.execute('SELECT * from foo;') + throw new Error('Expected UnknownError') } catch (err) { - expect(err).toEqual(new DatabaseError(mockError.message, 500, mockError)) + expect(err).toBeInstanceOf(UnknownError) + expect(err).toBeInstanceOf(DatabaseError) + const upstream = err as InstanceType + expect(upstream.status).toEqual(500) + expect(upstream.context.body).toContain('internal') } }) diff --git a/src/index.ts b/src/index.ts index f45a35a..146ac7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,22 @@ export class DatabaseError extends Error { } } +interface ResponseContext { + status: number + statusText: string + body: string + headers: Record +} + +export class UnknownError extends DatabaseError { + context: ResponseContext + constructor(message: string, context: ResponseContext) { + super(message, context.status, { code: 'UNKNOWN', message }) + this.name = 'UnknownError' + this.context = context + } +} + type Types = Record export interface ExecutedQuery | Row<'object'>> { @@ -50,8 +66,8 @@ type Res = { ok: boolean status: number statusText: string - json(): Promise text(): Promise + headers?: { forEach(fn: (value: string, key: string) => void): void } } export type Cast = typeof cast @@ -318,21 +334,43 @@ async function postJSON(config: Config, fetch: Fetch, url: string | URL, body cache: 'no-store' }) + const text = await response.text() + if (response.ok) { - return await response.json() - } else { - let error = null - try { - const e = (await response.json()).error - error = new DatabaseError(e.message, response.status, e) - } catch { - error = new DatabaseError(response.statusText, response.status, { - code: 'internal', - message: response.statusText - }) - } - throw error + return JSON.parse(text) } + + let parsed: { error?: VitessError } | undefined + try { + parsed = JSON.parse(text) + } catch { + // ignore + } + + if (parsed?.error) { + throw new DatabaseError(parsed.error.message, response.status, parsed.error) + } + + const headers: Record = {} + try { + response.headers?.forEach((value, key) => { + headers[key] = value + }) + } catch { + // ignore + } + + const context: ResponseContext = { + status: response.status, + statusText: response.statusText, + body: text.substring(0, 4096), + headers + } + + throw new UnknownError( + `Expected a JSON response from the database API but received: HTTP ${response.status} ${response.statusText}`, + context + ) } export function connect(config: Config): Connection {