Skip to content

Commit c1bb5f0

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

File tree

2 files changed

+197
-12
lines changed

2 files changed

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

0 commit comments

Comments
 (0)