Skip to content

Commit d7beb57

Browse files
authored
fix(postgrest): bubble up fetch error causes and codes (#1856)
1 parent ec445bb commit d7beb57

File tree

2 files changed

+206
-12
lines changed

2 files changed

+206
-12
lines changed

packages/core/postgrest-js/src/PostgrestBuilder.ts

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -209,18 +209,44 @@ export default abstract class PostgrestBuilder<
209209
return postgrestResponse
210210
})
211211
if (!this.shouldThrowOnError) {
212-
res = res.catch((fetchError) => ({
213-
error: {
214-
message: `${fetchError?.name ?? 'FetchError'}: ${fetchError?.message}`,
215-
details: `${fetchError?.stack ?? ''}`,
216-
hint: '',
217-
code: `${fetchError?.code ?? ''}`,
218-
},
219-
data: null,
220-
count: null,
221-
status: 0,
222-
statusText: '',
223-
}))
212+
res = res.catch((fetchError) => {
213+
// Build detailed error information including cause if available
214+
// Note: We don't populate code/hint for client-side network errors since those
215+
// fields are meant for upstream service errors (PostgREST/PostgreSQL)
216+
let errorDetails = ''
217+
218+
// Add cause information if available (e.g., DNS errors, network failures)
219+
const cause = fetchError?.cause
220+
if (cause) {
221+
const causeMessage = cause?.message ?? ''
222+
const causeCode = cause?.code ?? ''
223+
224+
errorDetails = `${fetchError?.name ?? 'FetchError'}: ${fetchError?.message}`
225+
errorDetails += `\n\nCaused by: ${cause?.name ?? 'Error'}: ${causeMessage}`
226+
if (causeCode) {
227+
errorDetails += ` (${causeCode})`
228+
}
229+
if (cause?.stack) {
230+
errorDetails += `\n${cause.stack}`
231+
}
232+
} else {
233+
// No cause available, just include the error stack
234+
errorDetails = fetchError?.stack ?? ''
235+
}
236+
237+
return {
238+
error: {
239+
message: `${fetchError?.name ?? 'FetchError'}: ${fetchError?.message}`,
240+
details: errorDetails,
241+
hint: '',
242+
code: '',
243+
},
244+
data: null,
245+
count: null,
246+
status: 0,
247+
statusText: '',
248+
}
249+
})
224250
}
225251

226252
return res.then(onfulfilled, onrejected)
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { PostgrestClient } from '../src/index'
2+
import { Database } from './types.override'
3+
4+
describe('Fetch error handling', () => {
5+
test('should bubble up DNS error cause in details', async () => {
6+
// Create a client with an invalid domain that will trigger DNS resolution error
7+
const postgrest = new PostgrestClient<Database>(
8+
'https://invalid-domain-that-does-not-exist.local'
9+
)
10+
11+
const res = await postgrest.from('users').select()
12+
13+
expect(res.error).toBeTruthy()
14+
expect(res.data).toBeNull()
15+
expect(res.status).toBe(0)
16+
expect(res.statusText).toBe('')
17+
18+
// Client-side network errors don't populate code/hint (those are for upstream service errors)
19+
expect(res.error!.code).toBe('')
20+
expect(res.error!.hint).toBe('')
21+
22+
// The message should contain the fetch error
23+
expect(res.error!.message).toContain('fetch failed')
24+
25+
// The details should contain cause information with error code
26+
// Different environments return different DNS error codes:
27+
// - ENOTFOUND: Domain doesn't exist (most common)
28+
// - EAI_AGAIN: Temporary DNS failure (common in CI)
29+
expect(res.error!.details).toContain('Caused by:')
30+
expect(res.error!.details).toContain('getaddrinfo')
31+
expect(res.error!.details).toMatch(/\(ENOTFOUND\)|\(EAI_AGAIN\)/)
32+
})
33+
34+
test('should handle network errors with custom fetch implementation', async () => {
35+
// Simulate a network error with a cause
36+
const mockFetch = jest.fn().mockRejectedValue(
37+
Object.assign(new TypeError('fetch failed'), {
38+
cause: Object.assign(new Error('getaddrinfo ENOTFOUND example.com'), {
39+
code: 'ENOTFOUND',
40+
errno: -3008,
41+
syscall: 'getaddrinfo',
42+
hostname: 'example.com',
43+
}),
44+
})
45+
)
46+
47+
const postgrest = new PostgrestClient<Database>('https://example.com', {
48+
fetch: mockFetch as any,
49+
})
50+
51+
const res = await postgrest.from('users').select()
52+
53+
expect(res.error).toBeTruthy()
54+
expect(res.error!.code).toBe('')
55+
expect(res.error!.hint).toBe('')
56+
expect(res.error!.message).toBe('TypeError: fetch failed')
57+
expect(res.error!.details).toContain('Caused by:')
58+
expect(res.error!.details).toContain('getaddrinfo ENOTFOUND example.com')
59+
expect(res.error!.details).toContain('(ENOTFOUND)')
60+
})
61+
62+
test('should handle connection refused errors', async () => {
63+
// Simulate a connection refused error
64+
const mockFetch = jest.fn().mockRejectedValue(
65+
Object.assign(new TypeError('fetch failed'), {
66+
cause: Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:9999'), {
67+
code: 'ECONNREFUSED',
68+
errno: -61,
69+
syscall: 'connect',
70+
address: '127.0.0.1',
71+
port: 9999,
72+
}),
73+
})
74+
)
75+
76+
const postgrest = new PostgrestClient<Database>('http://localhost:9999', {
77+
fetch: mockFetch as any,
78+
})
79+
80+
const res = await postgrest.from('users').select()
81+
82+
expect(res.error).toBeTruthy()
83+
expect(res.error!.code).toBe('')
84+
expect(res.error!.hint).toBe('')
85+
expect(res.error!.details).toContain('connect ECONNREFUSED')
86+
expect(res.error!.details).toContain('(ECONNREFUSED)')
87+
})
88+
89+
test('should handle timeout errors', async () => {
90+
// Simulate a timeout error
91+
const mockFetch = jest.fn().mockRejectedValue(
92+
Object.assign(new TypeError('fetch failed'), {
93+
cause: Object.assign(new Error('request timeout'), {
94+
code: 'ETIMEDOUT',
95+
errno: -60,
96+
syscall: 'connect',
97+
}),
98+
})
99+
)
100+
101+
const postgrest = new PostgrestClient<Database>('https://example.com', {
102+
fetch: mockFetch as any,
103+
})
104+
105+
const res = await postgrest.from('users').select()
106+
107+
expect(res.error).toBeTruthy()
108+
expect(res.error!.code).toBe('')
109+
expect(res.error!.hint).toBe('')
110+
expect(res.error!.details).toContain('request timeout')
111+
expect(res.error!.details).toContain('(ETIMEDOUT)')
112+
})
113+
114+
test('should handle fetch errors without cause gracefully', async () => {
115+
// Simulate a fetch error without cause
116+
const mockFetch = jest.fn().mockRejectedValue(
117+
Object.assign(new TypeError('fetch failed'), {
118+
code: 'FETCH_ERROR',
119+
})
120+
)
121+
122+
const postgrest = new PostgrestClient<Database>('https://example.com', {
123+
fetch: mockFetch as any,
124+
})
125+
126+
const res = await postgrest.from('users').select()
127+
128+
expect(res.error).toBeTruthy()
129+
expect(res.error!.code).toBe('')
130+
expect(res.error!.hint).toBe('')
131+
expect(res.error!.message).toBe('TypeError: fetch failed')
132+
// When no cause, details should still have the stack trace
133+
expect(res.error!.details).toBeTruthy()
134+
})
135+
136+
test('should handle generic errors without code', async () => {
137+
// Simulate a generic error
138+
const mockFetch = jest.fn().mockRejectedValue(new Error('Something went wrong'))
139+
140+
const postgrest = new PostgrestClient<Database>('https://example.com', {
141+
fetch: mockFetch as any,
142+
})
143+
144+
const res = await postgrest.from('users').select()
145+
146+
expect(res.error).toBeTruthy()
147+
expect(res.error!.code).toBe('')
148+
expect(res.error!.hint).toBe('')
149+
expect(res.error!.message).toBe('Error: Something went wrong')
150+
})
151+
152+
test('should throw error when using throwOnError with fetch failure', async () => {
153+
const mockFetch = jest.fn().mockRejectedValue(
154+
Object.assign(new TypeError('fetch failed'), {
155+
cause: Object.assign(new Error('getaddrinfo ENOTFOUND example.com'), {
156+
code: 'ENOTFOUND',
157+
}),
158+
})
159+
)
160+
161+
const postgrest = new PostgrestClient<Database>('https://example.com', {
162+
fetch: mockFetch as any,
163+
})
164+
165+
// When throwOnError is used, the error should be thrown instead of returned
166+
await expect(postgrest.from('users').select().throwOnError()).rejects.toThrow('fetch failed')
167+
})
168+
})

0 commit comments

Comments
 (0)