Skip to content

Commit 3bbf03c

Browse files
committed
Refactored to use AbortSignal API, error mapping improved
1 parent aec5384 commit 3bbf03c

6 files changed

Lines changed: 173 additions & 119 deletions

File tree

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,14 @@ shouldRetry: ({ attempt, request, response, error }) => {
154154

155155
### Custom Error Types
156156

157-
`ffetch` throws custom error classes for robust error handling. You can catch and handle these errors as needed:
157+
`ffetch` throws custom error classes for robust error handling. All custom errors have a `.cause` property:
158+
159+
- If the error is mapped from a native error (e.g., a DOMException or TypeError from fetch), `.cause` will reference the original error.
160+
- If the error is user-initiated (e.g., user aborts a request), `.cause` will be `undefined`.
161+
162+
This allows you to inspect the underlying cause for advanced debugging or cross-environment handling.
163+
164+
You can catch and handle these errors as needed:
158165

159166
- `TimeoutError`: The request timed out.
160167
- `AbortError`: The request was aborted by the user.

src/client.ts

Lines changed: 100 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { FFetchOptions, FFetch, FFetchRequestInit } from './types.js'
22
import { retry, defaultDelay } from './retry.js'
3-
// ...existing code...
43
import { shouldRetry as defaultShouldRetry } from './should-retry.js'
54
import { CircuitBreaker } from './circuit.js'
65
import {
@@ -32,48 +31,51 @@ export function createClient(opts: FFetchOptions = {}): FFetch {
3231
input: RequestInfo | URL,
3332
init: FFetchRequestInit = {}
3433
) => {
34+
// Check for AbortSignal.timeout before any async logic
35+
if (
36+
typeof AbortSignal === 'undefined' ||
37+
typeof AbortSignal.timeout !== 'function'
38+
) {
39+
throw new Error(
40+
'AbortSignal.timeout is required. Please use a polyfill for older environments.'
41+
)
42+
}
43+
3544
let request = new Request(input, init)
36-
// ...existing code...
45+
3746
// Merge hooks: per-request hooks override client hooks, but fallback to client hooks
3847
const effectiveHooks = { ...clientDefaultHooks, ...(init.hooks || {}) }
3948
if (effectiveHooks.transformRequest) {
4049
request = await effectiveHooks.transformRequest(request)
4150
}
4251
await effectiveHooks.before?.(request)
43-
// Combine two signals so abort from either source will abort the request
44-
// ...existing code...
52+
53+
// AbortSignal.timeout/any logic ---
54+
const effectiveTimeout = init.timeout ?? clientDefaultTimeout
55+
const userSignal = init.signal
56+
let timeoutSignal: AbortSignal | undefined = undefined
57+
let combinedSignal: AbortSignal | undefined = undefined
58+
timeoutSignal = AbortSignal.timeout(effectiveTimeout)
59+
if (userSignal) {
60+
if (typeof AbortSignal.any === 'function') {
61+
combinedSignal = AbortSignal.any([userSignal, timeoutSignal])
62+
} else {
63+
// Fallback: use userSignal if already aborted, else timeoutSignal
64+
combinedSignal = userSignal.aborted ? userSignal : timeoutSignal
65+
}
66+
} else {
67+
combinedSignal = timeoutSignal
68+
}
4569

4670
// Restore hook-wrapped retry, enforce global timeout externally
4771
const retryWithHooks = async () => {
48-
const effectiveTimeout = init.timeout ?? clientDefaultTimeout
4972
const effectiveRetries = init.retries ?? clientDefaultRetries
5073
const effectiveRetryDelay =
5174
typeof init.retryDelay !== 'undefined'
5275
? init.retryDelay
5376
: clientDefaultRetryDelay
5477
const effectiveShouldRetry = init.shouldRetry ?? clientDefaultShouldRetry
5578

56-
// Global timeout controller and elapsed time tracking
57-
const timeoutCtrl = new AbortController()
58-
const startTime = Date.now()
59-
let didTimeout = false
60-
const timeoutTimer = setTimeout(() => {
61-
didTimeout = true
62-
timeoutCtrl.abort()
63-
}, effectiveTimeout)
64-
65-
// Compose user and timeout signals
66-
const userSignal = init.signal || undefined
67-
function combinedSignal() {
68-
if (!userSignal) return timeoutCtrl.signal
69-
if (userSignal.aborted) {
70-
timeoutCtrl.abort()
71-
return timeoutCtrl.signal
72-
}
73-
userSignal.addEventListener('abort', () => timeoutCtrl.abort())
74-
return timeoutCtrl.signal
75-
}
76-
7779
// Wrap shouldRetry to call onRetry hook
7880
let attempt = 0
7981
const shouldRetryWithHook = (ctx: import('./types').RetryContext) => {
@@ -90,91 +92,105 @@ export function createClient(opts: FFetchOptions = {}): FFetch {
9092
return retrying
9193
}
9294

95+
function mapToCustomError(err: unknown): unknown {
96+
if (err instanceof DOMException && err.name === 'AbortError') {
97+
if (timeoutSignal?.aborted && (!userSignal || !userSignal.aborted)) {
98+
return new TimeoutError('signal timed out', err)
99+
} else {
100+
return new AbortError('Request was aborted', err)
101+
}
102+
} else if (
103+
err instanceof TypeError &&
104+
/NetworkError|network error|failed to fetch|lost connection|NetworkError when attempting to fetch resource/i.test(
105+
err.message
106+
)
107+
) {
108+
return new NetworkError(err.message, err)
109+
}
110+
return err
111+
}
112+
113+
async function handleError(err: unknown): Promise<never> {
114+
err = mapToCustomError(err)
115+
// If user aborted, always throw AbortError, not RetryLimitError
116+
if (userSignal?.aborted) {
117+
const abortErr = new AbortError('Request was aborted by user')
118+
await effectiveHooks.onAbort?.(request)
119+
await effectiveHooks.onError?.(request, abortErr)
120+
await effectiveHooks.onComplete?.(request, undefined, abortErr)
121+
throw abortErr
122+
}
123+
// TimeoutError: call onTimeout hook and re-throw
124+
if (err instanceof TimeoutError) {
125+
await effectiveHooks.onTimeout?.(request)
126+
await effectiveHooks.onError?.(request, err)
127+
await effectiveHooks.onComplete?.(request, undefined, err)
128+
throw err
129+
}
130+
// NetworkError: re-throw
131+
if (err instanceof NetworkError) {
132+
await effectiveHooks.onError?.(request, err)
133+
await effectiveHooks.onComplete?.(request, undefined, err)
134+
throw err
135+
}
136+
// AbortError: call onAbort hook and re-throw
137+
if (err instanceof AbortError) {
138+
await effectiveHooks.onAbort?.(request)
139+
await effectiveHooks.onError?.(request, err)
140+
await effectiveHooks.onComplete?.(request, undefined, err)
141+
throw err
142+
}
143+
// Otherwise, throw RetryLimitError after all retries are exhausted
144+
const retryErr = new RetryLimitError(
145+
typeof err === 'object' &&
146+
err &&
147+
'message' in err &&
148+
typeof (err as { message?: unknown }).message === 'string'
149+
? (err as { message: string }).message
150+
: 'Retry limit reached'
151+
)
152+
await effectiveHooks.onError?.(request, retryErr)
153+
await effectiveHooks.onComplete?.(request, undefined, retryErr)
154+
throw retryErr
155+
}
156+
93157
try {
94158
let res = await retry(
95159
async () => {
96-
// Check elapsed time before each attempt
97-
const elapsed = Date.now() - startTime
98-
if (elapsed >= effectiveTimeout) {
99-
didTimeout = true
100-
await effectiveHooks.onTimeout?.(request)
101-
throw new TimeoutError('Request timed out')
160+
// Use AbortSignal.throwIfAborted() before starting fetch
161+
if (typeof combinedSignal?.throwIfAborted === 'function') {
162+
combinedSignal.throwIfAborted()
163+
} else if (combinedSignal?.aborted) {
164+
throw new AbortError('Request was aborted')
102165
}
103166
const reqWithSignal = new Request(request, {
104-
signal: combinedSignal(),
167+
signal: combinedSignal,
105168
})
106169
try {
107170
const r = await fetch(reqWithSignal)
108171
return r
109172
} catch (err: unknown) {
110-
if (err instanceof DOMException && err.name === 'AbortError') {
111-
// Check elapsed time after abort
112-
const elapsedAbort = Date.now() - startTime
113-
if (userSignal?.aborted) {
114-
await effectiveHooks.onAbort?.(request)
115-
throw new AbortError('Request was aborted')
116-
} else if (didTimeout || elapsedAbort >= effectiveTimeout) {
117-
await effectiveHooks.onTimeout?.(request)
118-
throw new TimeoutError('Request timed out')
119-
} else {
120-
throw new AbortError('Request was aborted')
121-
}
122-
} else if (
123-
err instanceof Error &&
124-
(err.message.includes('timeout') || err.name === 'TimeoutError')
125-
) {
126-
await effectiveHooks.onTimeout?.(request)
127-
throw new TimeoutError(err.message)
128-
} else if (
129-
err instanceof TypeError &&
130-
err.message &&
131-
err.message.includes('NetworkError')
132-
) {
133-
throw new NetworkError(err.message)
134-
}
135-
throw err
173+
await handleError(mapToCustomError(err))
174+
throw new Error('Unreachable: handleError should always throw')
136175
}
137176
},
138177
effectiveRetries,
139178
effectiveRetryDelay,
140179
shouldRetryWithHook,
141180
request
142181
)
143-
clearTimeout(timeoutTimer)
144182
if (effectiveHooks.transformResponse) {
145183
res = await effectiveHooks.transformResponse(res, request)
146184
}
147185
await effectiveHooks.after?.(request, res)
148186
await effectiveHooks.onComplete?.(request, res, undefined)
149187
return res
150188
} catch (err: unknown) {
151-
clearTimeout(timeoutTimer)
152-
// If the error is a known custom error, re-throw it directly
153-
if (
154-
err instanceof TimeoutError ||
155-
err instanceof AbortError ||
156-
err instanceof NetworkError
157-
) {
158-
await effectiveHooks.onError?.(request, err)
159-
await effectiveHooks.onComplete?.(request, undefined, err)
160-
throw err
161-
}
162-
// Otherwise, throw RetryLimitError after all retries are exhausted
163-
const retryErr = new RetryLimitError(
164-
typeof err === 'object' &&
165-
err &&
166-
'message' in err &&
167-
typeof (err as { message?: unknown }).message === 'string'
168-
? (err as { message: string }).message
169-
: 'Retry limit reached'
170-
)
171-
await effectiveHooks.onError?.(request, retryErr)
172-
await effectiveHooks.onComplete?.(request, undefined, retryErr)
173-
throw retryErr
189+
await handleError(mapToCustomError(err))
190+
throw new Error('Unreachable: handleError should always throw')
174191
}
175192
}
176193

177-
// ...replaced above...
178194
if (breaker) {
179195
try {
180196
return await breaker.invoke(retryWithHooks)

src/error.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,57 @@
11
// Custom error classes
22

33
export class TimeoutError extends Error {
4-
constructor(message = 'Request timed out') {
4+
public cause?: unknown
5+
constructor(message = 'Request timed out', cause?: unknown) {
56
super(message)
67
this.name = 'TimeoutError'
8+
if (cause !== undefined) this.cause = cause
79
}
810
}
911

1012
export class CircuitOpenError extends Error {
11-
constructor(message = 'Circuit is open') {
13+
public cause?: unknown
14+
constructor(message = 'Circuit is open', cause?: unknown) {
1215
super(message)
1316
this.name = 'CircuitOpenError'
17+
if (cause !== undefined) this.cause = cause
1418
}
1519
}
1620

1721
export class AbortError extends Error {
18-
constructor(message = 'Request was aborted') {
22+
public cause?: unknown
23+
constructor(message = 'Request was aborted', cause?: unknown) {
1924
super(message)
2025
this.name = 'AbortError'
26+
if (cause !== undefined) this.cause = cause
2127
}
2228
}
2329

2430
export class RetryLimitError extends Error {
25-
constructor(message = 'Retry limit reached') {
31+
public cause?: unknown
32+
constructor(message = 'Retry limit reached', cause?: unknown) {
2633
super(message)
2734
this.name = 'RetryLimitError'
35+
if (cause !== undefined) this.cause = cause
2836
}
2937
}
3038

3139
export class NetworkError extends Error {
32-
constructor(message = 'Network error occurred') {
40+
public cause?: unknown
41+
constructor(message = 'Network error occurred', cause?: unknown) {
3342
super(message)
3443
this.name = 'NetworkError'
44+
if (cause !== undefined) this.cause = cause
3545
}
3646
}
3747

3848
export class ResponseError extends Error {
3949
public response: Response
40-
constructor(response: Response, message = 'Response error') {
50+
public cause?: unknown
51+
constructor(response: Response, message = 'Response error', cause?: unknown) {
4152
super(message)
4253
this.name = 'ResponseError'
4354
this.response = response
55+
if (cause !== undefined) this.cause = cause
4456
}
4557
}

0 commit comments

Comments
 (0)