Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 38 additions & 12 deletions packages/core/postgrest-js/src/PostgrestBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,18 +209,44 @@ 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) => {
// 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 = ''

// 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}`
}
} else {
// No cause available, just include the error stack
errorDetails = fetchError?.stack ?? ''
}

return {
error: {
message: `${fetchError?.name ?? 'FetchError'}: ${fetchError?.message}`,
details: errorDetails,
hint: '',
code: '',
},
data: null,
count: null,
status: 0,
statusText: '',
}
})
}

return res.then(onfulfilled, onrejected)
Expand Down
168 changes: 168 additions & 0 deletions packages/core/postgrest-js/test/fetch-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { PostgrestClient } from '../src/index'
import { Database } from './types.override'

describe('Fetch error handling', () => {
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<Database>(
'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('')

// 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 contain the fetch error
expect(res.error!.message).toContain('fetch failed')

// 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).toContain('getaddrinfo')
expect(res.error!.details).toMatch(/\(ENOTFOUND\)|\(EAI_AGAIN\)/)
})

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<Database>('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!.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('(ENOTFOUND)')
})

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<Database>('http://localhost:9999', {
fetch: mockFetch as any,
})

const res = await postgrest.from('users').select()

expect(res.error).toBeTruthy()
expect(res.error!.code).toBe('')
expect(res.error!.hint).toBe('')
expect(res.error!.details).toContain('connect ECONNREFUSED')
expect(res.error!.details).toContain('(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<Database>('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!.hint).toBe('')
expect(res.error!.details).toContain('request timeout')
expect(res.error!.details).toContain('(ETIMEDOUT)')
})

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<Database>('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!.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 () => {
// Simulate a generic error
const mockFetch = jest.fn().mockRejectedValue(new Error('Something went wrong'))

const postgrest = new PostgrestClient<Database>('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!.hint).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<Database>('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')
})
})
Loading