From c1bb5f005c01b4801d13b56452b72af8a50ce550 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 12 Nov 2025 14:23:26 +0200 Subject: [PATCH 1/2] fix(postgrest): bubble up fetch error causes and codes --- .../core/postgrest-js/src/PostgrestBuilder.ts | 46 +++-- .../postgrest-js/test/fetch-errors.test.ts | 163 ++++++++++++++++++ 2 files changed, 197 insertions(+), 12 deletions(-) create mode 100644 packages/core/postgrest-js/test/fetch-errors.test.ts diff --git a/packages/core/postgrest-js/src/PostgrestBuilder.ts b/packages/core/postgrest-js/src/PostgrestBuilder.ts index a13d2c50d..cd5cdb53f 100644 --- a/packages/core/postgrest-js/src/PostgrestBuilder.ts +++ b/packages/core/postgrest-js/src/PostgrestBuilder.ts @@ -209,18 +209,40 @@ export default abstract class PostgrestBuilder< return postgrestResponse }) if (!this.shouldThrowOnError) { - res = res.catch((fetchError) => ({ - error: { - message: `${fetchError?.name ?? 'FetchError'}: ${fetchError?.message}`, - details: `${fetchError?.stack ?? ''}`, - hint: '', - code: `${fetchError?.code ?? ''}`, - }, - data: null, - count: null, - status: 0, - statusText: '', - })) + res = res.catch((fetchError) => { + // Extract cause information if available (e.g., DNS errors, network failures) + const cause = fetchError?.cause + const causeCode = cause?.code ?? '' + const causeMessage = cause?.message ?? '' + + // Prefer the underlying cause code (e.g., ENOTFOUND) over the wrapper error code + const errorCode = causeCode || fetchError?.code || '' + + // Build a detailed error message that includes cause information + let errorDetails = fetchError?.stack ?? '' + if (cause) { + errorDetails += `\n\nCaused by: ${cause?.name ?? 'Error'}: ${causeMessage}` + if (cause?.stack) { + errorDetails += `\n${cause.stack}` + } + if (causeCode) { + errorDetails += `\nError code: ${causeCode}` + } + } + + return { + error: { + message: `${fetchError?.name ?? 'FetchError'}: ${fetchError?.message}`, + details: errorDetails, + hint: causeMessage ? `Underlying cause: ${causeMessage}` : '', + code: errorCode, + }, + data: null, + count: null, + status: 0, + statusText: '', + } + }) } return res.then(onfulfilled, onrejected) diff --git a/packages/core/postgrest-js/test/fetch-errors.test.ts b/packages/core/postgrest-js/test/fetch-errors.test.ts new file mode 100644 index 000000000..5b1087735 --- /dev/null +++ b/packages/core/postgrest-js/test/fetch-errors.test.ts @@ -0,0 +1,163 @@ +import { PostgrestClient } from '../src/index' +import { Database } from './types.override' + +describe('Fetch error handling', () => { + test('should bubble up DNS error code (ENOTFOUND or EAI_AGAIN) from fetch cause', async () => { + // Create a client with an invalid domain that will trigger DNS resolution error + const postgrest = new PostgrestClient( + 'https://invalid-domain-that-does-not-exist.local' + ) + + const res = await postgrest.from('users').select() + + expect(res.error).toBeTruthy() + expect(res.data).toBeNull() + expect(res.status).toBe(0) + expect(res.statusText).toBe('') + + // The error code should be a DNS error code from the cause + // Different environments return different DNS error codes: + // - ENOTFOUND: Domain doesn't exist (most common) + // - EAI_AGAIN: Temporary DNS failure (common in CI) + expect(['ENOTFOUND', 'EAI_AGAIN']).toContain(res.error!.code) + + // The message should still contain the fetch error + expect(res.error!.message).toContain('fetch failed') + + // The details should contain cause information + expect(res.error!.details).toContain('Caused by:') + expect(res.error!.details).toMatch(/ENOTFOUND|EAI_AGAIN/) + + // The hint should contain the underlying cause message with getaddrinfo + expect(res.error!.hint).toContain('getaddrinfo') + }) + + test('should handle network errors with custom fetch implementation', async () => { + // Simulate a network error with a cause + const mockFetch = jest.fn().mockRejectedValue( + Object.assign(new TypeError('fetch failed'), { + cause: Object.assign(new Error('getaddrinfo ENOTFOUND example.com'), { + code: 'ENOTFOUND', + errno: -3008, + syscall: 'getaddrinfo', + hostname: 'example.com', + }), + }) + ) + + const postgrest = new PostgrestClient('https://example.com', { + fetch: mockFetch as any, + }) + + const res = await postgrest.from('users').select() + + expect(res.error).toBeTruthy() + expect(res.error!.code).toBe('ENOTFOUND') + expect(res.error!.message).toBe('TypeError: fetch failed') + expect(res.error!.details).toContain('Caused by:') + expect(res.error!.details).toContain('getaddrinfo ENOTFOUND example.com') + expect(res.error!.details).toContain('Error code: ENOTFOUND') + expect(res.error!.hint).toContain('getaddrinfo ENOTFOUND example.com') + }) + + test('should handle connection refused errors', async () => { + // Simulate a connection refused error + const mockFetch = jest.fn().mockRejectedValue( + Object.assign(new TypeError('fetch failed'), { + cause: Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:9999'), { + code: 'ECONNREFUSED', + errno: -61, + syscall: 'connect', + address: '127.0.0.1', + port: 9999, + }), + }) + ) + + const postgrest = new PostgrestClient('http://localhost:9999', { + fetch: mockFetch as any, + }) + + const res = await postgrest.from('users').select() + + expect(res.error).toBeTruthy() + expect(res.error!.code).toBe('ECONNREFUSED') + expect(res.error!.details).toContain('connect ECONNREFUSED') + expect(res.error!.hint).toContain('connect ECONNREFUSED') + }) + + test('should handle timeout errors', async () => { + // Simulate a timeout error + const mockFetch = jest.fn().mockRejectedValue( + Object.assign(new TypeError('fetch failed'), { + cause: Object.assign(new Error('request timeout'), { + code: 'ETIMEDOUT', + errno: -60, + syscall: 'connect', + }), + }) + ) + + const postgrest = new PostgrestClient('https://example.com', { + fetch: mockFetch as any, + }) + + const res = await postgrest.from('users').select() + + expect(res.error).toBeTruthy() + expect(res.error!.code).toBe('ETIMEDOUT') + expect(res.error!.details).toContain('request timeout') + }) + + test('should handle fetch errors without cause gracefully', async () => { + // Simulate a fetch error without cause + const mockFetch = jest.fn().mockRejectedValue( + Object.assign(new TypeError('fetch failed'), { + code: 'FETCH_ERROR', + }) + ) + + const postgrest = new PostgrestClient('https://example.com', { + fetch: mockFetch as any, + }) + + const res = await postgrest.from('users').select() + + expect(res.error).toBeTruthy() + expect(res.error!.code).toBe('FETCH_ERROR') + expect(res.error!.message).toBe('TypeError: fetch failed') + expect(res.error!.hint).toBe('') + }) + + test('should handle generic errors without code', async () => { + // Simulate a generic error + const mockFetch = jest.fn().mockRejectedValue(new Error('Something went wrong')) + + const postgrest = new PostgrestClient('https://example.com', { + fetch: mockFetch as any, + }) + + const res = await postgrest.from('users').select() + + expect(res.error).toBeTruthy() + expect(res.error!.code).toBe('') + expect(res.error!.message).toBe('Error: Something went wrong') + }) + + test('should throw error when using throwOnError with fetch failure', async () => { + const mockFetch = jest.fn().mockRejectedValue( + Object.assign(new TypeError('fetch failed'), { + cause: Object.assign(new Error('getaddrinfo ENOTFOUND example.com'), { + code: 'ENOTFOUND', + }), + }) + ) + + const postgrest = new PostgrestClient('https://example.com', { + fetch: mockFetch as any, + }) + + // When throwOnError is used, the error should be thrown instead of returned + await expect(postgrest.from('users').select().throwOnError()).rejects.toThrow('fetch failed') + }) +}) From be21e84cdc6c890ccaad8aee80d91121131d721a Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 12 Nov 2025 17:41:13 +0200 Subject: [PATCH 2/2] fix(postgrest): expose fetch error causes in details field --- .../core/postgrest-js/src/PostgrestBuilder.ts | 32 +++++++------ .../postgrest-js/test/fetch-errors.test.ts | 45 ++++++++++--------- 2 files changed, 43 insertions(+), 34 deletions(-) diff --git a/packages/core/postgrest-js/src/PostgrestBuilder.ts b/packages/core/postgrest-js/src/PostgrestBuilder.ts index cd5cdb53f..f7d05bb34 100644 --- a/packages/core/postgrest-js/src/PostgrestBuilder.ts +++ b/packages/core/postgrest-js/src/PostgrestBuilder.ts @@ -210,32 +210,36 @@ export default abstract class PostgrestBuilder< }) if (!this.shouldThrowOnError) { res = res.catch((fetchError) => { - // Extract cause information if available (e.g., DNS errors, network failures) - const cause = fetchError?.cause - const causeCode = cause?.code ?? '' - const causeMessage = cause?.message ?? '' - - // Prefer the underlying cause code (e.g., ENOTFOUND) over the wrapper error code - const errorCode = causeCode || fetchError?.code || '' + // Build detailed error information including cause if available + // Note: We don't populate code/hint for client-side network errors since those + // fields are meant for upstream service errors (PostgREST/PostgreSQL) + let errorDetails = '' - // Build a detailed error message that includes cause information - let errorDetails = fetchError?.stack ?? '' + // Add cause information if available (e.g., DNS errors, network failures) + const cause = fetchError?.cause if (cause) { + const causeMessage = cause?.message ?? '' + const causeCode = cause?.code ?? '' + + errorDetails = `${fetchError?.name ?? 'FetchError'}: ${fetchError?.message}` errorDetails += `\n\nCaused by: ${cause?.name ?? 'Error'}: ${causeMessage}` + if (causeCode) { + errorDetails += ` (${causeCode})` + } if (cause?.stack) { errorDetails += `\n${cause.stack}` } - if (causeCode) { - errorDetails += `\nError code: ${causeCode}` - } + } else { + // No cause available, just include the error stack + errorDetails = fetchError?.stack ?? '' } return { error: { message: `${fetchError?.name ?? 'FetchError'}: ${fetchError?.message}`, details: errorDetails, - hint: causeMessage ? `Underlying cause: ${causeMessage}` : '', - code: errorCode, + hint: '', + code: '', }, data: null, count: null, diff --git a/packages/core/postgrest-js/test/fetch-errors.test.ts b/packages/core/postgrest-js/test/fetch-errors.test.ts index 5b1087735..2d8245bc4 100644 --- a/packages/core/postgrest-js/test/fetch-errors.test.ts +++ b/packages/core/postgrest-js/test/fetch-errors.test.ts @@ -2,7 +2,7 @@ import { PostgrestClient } from '../src/index' import { Database } from './types.override' describe('Fetch error handling', () => { - test('should bubble up DNS error code (ENOTFOUND or EAI_AGAIN) from fetch cause', async () => { + test('should bubble up DNS error cause in details', async () => { // Create a client with an invalid domain that will trigger DNS resolution error const postgrest = new PostgrestClient( 'https://invalid-domain-that-does-not-exist.local' @@ -15,21 +15,20 @@ describe('Fetch error handling', () => { expect(res.status).toBe(0) expect(res.statusText).toBe('') - // The error code should be a DNS error code from the cause - // Different environments return different DNS error codes: - // - ENOTFOUND: Domain doesn't exist (most common) - // - EAI_AGAIN: Temporary DNS failure (common in CI) - expect(['ENOTFOUND', 'EAI_AGAIN']).toContain(res.error!.code) + // Client-side network errors don't populate code/hint (those are for upstream service errors) + expect(res.error!.code).toBe('') + expect(res.error!.hint).toBe('') - // The message should still contain the fetch error + // The message should contain the fetch error expect(res.error!.message).toContain('fetch failed') - // The details should contain cause information + // The details should contain cause information with error code + // Different environments return different DNS error codes: + // - ENOTFOUND: Domain doesn't exist (most common) + // - EAI_AGAIN: Temporary DNS failure (common in CI) expect(res.error!.details).toContain('Caused by:') - expect(res.error!.details).toMatch(/ENOTFOUND|EAI_AGAIN/) - - // The hint should contain the underlying cause message with getaddrinfo - expect(res.error!.hint).toContain('getaddrinfo') + expect(res.error!.details).toContain('getaddrinfo') + expect(res.error!.details).toMatch(/\(ENOTFOUND\)|\(EAI_AGAIN\)/) }) test('should handle network errors with custom fetch implementation', async () => { @@ -52,12 +51,12 @@ describe('Fetch error handling', () => { const res = await postgrest.from('users').select() expect(res.error).toBeTruthy() - expect(res.error!.code).toBe('ENOTFOUND') + expect(res.error!.code).toBe('') + expect(res.error!.hint).toBe('') expect(res.error!.message).toBe('TypeError: fetch failed') expect(res.error!.details).toContain('Caused by:') expect(res.error!.details).toContain('getaddrinfo ENOTFOUND example.com') - expect(res.error!.details).toContain('Error code: ENOTFOUND') - expect(res.error!.hint).toContain('getaddrinfo ENOTFOUND example.com') + expect(res.error!.details).toContain('(ENOTFOUND)') }) test('should handle connection refused errors', async () => { @@ -81,9 +80,10 @@ describe('Fetch error handling', () => { const res = await postgrest.from('users').select() expect(res.error).toBeTruthy() - expect(res.error!.code).toBe('ECONNREFUSED') + expect(res.error!.code).toBe('') + expect(res.error!.hint).toBe('') expect(res.error!.details).toContain('connect ECONNREFUSED') - expect(res.error!.hint).toContain('connect ECONNREFUSED') + expect(res.error!.details).toContain('(ECONNREFUSED)') }) test('should handle timeout errors', async () => { @@ -105,8 +105,10 @@ describe('Fetch error handling', () => { const res = await postgrest.from('users').select() expect(res.error).toBeTruthy() - expect(res.error!.code).toBe('ETIMEDOUT') + expect(res.error!.code).toBe('') + expect(res.error!.hint).toBe('') expect(res.error!.details).toContain('request timeout') + expect(res.error!.details).toContain('(ETIMEDOUT)') }) test('should handle fetch errors without cause gracefully', async () => { @@ -124,9 +126,11 @@ describe('Fetch error handling', () => { const res = await postgrest.from('users').select() expect(res.error).toBeTruthy() - expect(res.error!.code).toBe('FETCH_ERROR') - expect(res.error!.message).toBe('TypeError: fetch failed') + expect(res.error!.code).toBe('') expect(res.error!.hint).toBe('') + expect(res.error!.message).toBe('TypeError: fetch failed') + // When no cause, details should still have the stack trace + expect(res.error!.details).toBeTruthy() }) test('should handle generic errors without code', async () => { @@ -141,6 +145,7 @@ describe('Fetch error handling', () => { expect(res.error).toBeTruthy() expect(res.error!.code).toBe('') + expect(res.error!.hint).toBe('') expect(res.error!.message).toBe('Error: Something went wrong') })