Skip to content

Commit 670b7d4

Browse files
committed
fix(postgrest): bubble up fetch error causes and codes
1 parent ec445bb commit 670b7d4

File tree

2 files changed

+194
-12
lines changed

2 files changed

+194
-12
lines changed

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

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -209,18 +209,40 @@ 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+
// Extract cause information if available (e.g., DNS errors, network failures)
214+
const cause = fetchError?.cause
215+
const causeCode = cause?.code ?? ''
216+
const causeMessage = cause?.message ?? ''
217+
218+
// Prefer the underlying cause code (e.g., ENOTFOUND) over the wrapper error code
219+
const errorCode = causeCode || fetchError?.code || ''
220+
221+
// Build a detailed error message that includes cause information
222+
let errorDetails = fetchError?.stack ?? ''
223+
if (cause) {
224+
errorDetails += `\n\nCaused by: ${cause?.name ?? 'Error'}: ${causeMessage}`
225+
if (cause?.stack) {
226+
errorDetails += `\n${cause.stack}`
227+
}
228+
if (causeCode) {
229+
errorDetails += `\nError code: ${causeCode}`
230+
}
231+
}
232+
233+
return {
234+
error: {
235+
message: `${fetchError?.name ?? 'FetchError'}: ${fetchError?.message}`,
236+
details: errorDetails,
237+
hint: causeMessage ? `Underlying cause: ${causeMessage}` : '',
238+
code: errorCode,
239+
},
240+
data: null,
241+
count: null,
242+
status: 0,
243+
statusText: '',
244+
}
245+
})
224246
}
225247

226248
return res.then(onfulfilled, onrejected)
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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 code (ENOTFOUND) from fetch cause', 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+
// The error code should be ENOTFOUND from the cause
19+
expect(res.error!.code).toBe('ENOTFOUND')
20+
21+
// The message should still contain the fetch error
22+
expect(res.error!.message).toContain('fetch failed')
23+
24+
// The details should contain cause information
25+
expect(res.error!.details).toContain('Caused by:')
26+
expect(res.error!.details).toContain('ENOTFOUND')
27+
28+
// The hint should contain the underlying cause message
29+
expect(res.error!.hint).toContain('getaddrinfo ENOTFOUND')
30+
})
31+
32+
test('should handle network errors with custom fetch implementation', async () => {
33+
// Simulate a network error with a cause
34+
const mockFetch = jest.fn().mockRejectedValue(
35+
Object.assign(new TypeError('fetch failed'), {
36+
cause: Object.assign(new Error('getaddrinfo ENOTFOUND example.com'), {
37+
code: 'ENOTFOUND',
38+
errno: -3008,
39+
syscall: 'getaddrinfo',
40+
hostname: 'example.com',
41+
}),
42+
})
43+
)
44+
45+
const postgrest = new PostgrestClient<Database>('https://example.com', {
46+
fetch: mockFetch as any,
47+
})
48+
49+
const res = await postgrest.from('users').select()
50+
51+
expect(res.error).toBeTruthy()
52+
expect(res.error!.code).toBe('ENOTFOUND')
53+
expect(res.error!.message).toBe('TypeError: fetch failed')
54+
expect(res.error!.details).toContain('Caused by:')
55+
expect(res.error!.details).toContain('getaddrinfo ENOTFOUND example.com')
56+
expect(res.error!.details).toContain('Error code: ENOTFOUND')
57+
expect(res.error!.hint).toContain('getaddrinfo ENOTFOUND example.com')
58+
})
59+
60+
test('should handle connection refused errors', async () => {
61+
// Simulate a connection refused error
62+
const mockFetch = jest.fn().mockRejectedValue(
63+
Object.assign(new TypeError('fetch failed'), {
64+
cause: Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:9999'), {
65+
code: 'ECONNREFUSED',
66+
errno: -61,
67+
syscall: 'connect',
68+
address: '127.0.0.1',
69+
port: 9999,
70+
}),
71+
})
72+
)
73+
74+
const postgrest = new PostgrestClient<Database>('http://localhost:9999', {
75+
fetch: mockFetch as any,
76+
})
77+
78+
const res = await postgrest.from('users').select()
79+
80+
expect(res.error).toBeTruthy()
81+
expect(res.error!.code).toBe('ECONNREFUSED')
82+
expect(res.error!.details).toContain('connect ECONNREFUSED')
83+
expect(res.error!.hint).toContain('connect ECONNREFUSED')
84+
})
85+
86+
test('should handle timeout errors', async () => {
87+
// Simulate a timeout error
88+
const mockFetch = jest.fn().mockRejectedValue(
89+
Object.assign(new TypeError('fetch failed'), {
90+
cause: Object.assign(new Error('request timeout'), {
91+
code: 'ETIMEDOUT',
92+
errno: -60,
93+
syscall: 'connect',
94+
}),
95+
})
96+
)
97+
98+
const postgrest = new PostgrestClient<Database>('https://example.com', {
99+
fetch: mockFetch as any,
100+
})
101+
102+
const res = await postgrest.from('users').select()
103+
104+
expect(res.error).toBeTruthy()
105+
expect(res.error!.code).toBe('ETIMEDOUT')
106+
expect(res.error!.details).toContain('request timeout')
107+
})
108+
109+
test('should handle fetch errors without cause gracefully', async () => {
110+
// Simulate a fetch error without cause
111+
const mockFetch = jest.fn().mockRejectedValue(
112+
Object.assign(new TypeError('fetch failed'), {
113+
code: 'FETCH_ERROR',
114+
})
115+
)
116+
117+
const postgrest = new PostgrestClient<Database>('https://example.com', {
118+
fetch: mockFetch as any,
119+
})
120+
121+
const res = await postgrest.from('users').select()
122+
123+
expect(res.error).toBeTruthy()
124+
expect(res.error!.code).toBe('FETCH_ERROR')
125+
expect(res.error!.message).toBe('TypeError: fetch failed')
126+
expect(res.error!.hint).toBe('')
127+
})
128+
129+
test('should handle generic errors without code', async () => {
130+
// Simulate a generic error
131+
const mockFetch = jest.fn().mockRejectedValue(new Error('Something went wrong'))
132+
133+
const postgrest = new PostgrestClient<Database>('https://example.com', {
134+
fetch: mockFetch as any,
135+
})
136+
137+
const res = await postgrest.from('users').select()
138+
139+
expect(res.error).toBeTruthy()
140+
expect(res.error!.code).toBe('')
141+
expect(res.error!.message).toBe('Error: Something went wrong')
142+
})
143+
144+
test('should throw error when using throwOnError with fetch failure', async () => {
145+
const mockFetch = jest.fn().mockRejectedValue(
146+
Object.assign(new TypeError('fetch failed'), {
147+
cause: Object.assign(new Error('getaddrinfo ENOTFOUND example.com'), {
148+
code: 'ENOTFOUND',
149+
}),
150+
})
151+
)
152+
153+
const postgrest = new PostgrestClient<Database>('https://example.com', {
154+
fetch: mockFetch as any,
155+
})
156+
157+
// When throwOnError is used, the error should be thrown instead of returned
158+
await expect(postgrest.from('users').select().throwOnError()).rejects.toThrow('fetch failed')
159+
})
160+
})

0 commit comments

Comments
 (0)