The native fetch API does not throw errors for HTTP error status codes (4xx, 5xx). Instead, it returns a Response object with the corresponding status code, and it is up to the developer to check response.ok or response.status to handle such cases. This is ffetch's default behavior as well, but it can be configured to throw errors for HTTP error responses using the throwOnHttpError flag.
This document explains exactly how, when, and what errors are thrown by the ffetch client, especially in relation to the throwOnHttpError flag.
See also:
- api.md for option reference (
throwOnHttpError, retries, plugin configuration) - advanced.md -> Custom Error Handling for runtime handling patterns
- advanced.md -> Circuit Breaker Pattern for circuit-specific failure behavior
| Scenario | throwOnHttpError: true | throwOnHttpError: false | What gets thrown/returned |
|---|---|---|---|
| HTTP 2xx (success) | — | — | Returns Response |
| HTTP 4xx after retries | throws | returns | Throws HttpError / Returns Response |
| HTTP 5xx after retries | throws | returns | Throws HttpError / Returns Response |
| Network error after retries | throws | throws | Throws NetworkError |
| Circuit breaker open | throws | throws | Throws CircuitOpenError |
| Timeout (request times out) | throws | throws | Throws TimeoutError |
| Aborted by user | throws | throws | Throws AbortError |
| Retry limit reached (other errors) | throws | throws | Throws RetryLimitError |
transformResponse/after throws |
throws | throws | Throws the hook's error |
| Plugin refuses the request | throws | throws | Throws the plugin's error |
The error classes are shared between the root entrypoint and the plugin subpaths, so they keep a single identity across the package. An error thrown by a plugin imported from @fetchkit/ffetch/plugins/* therefore satisfies instanceof against the class imported from @fetchkit/ffetch - for example, a CircuitOpenError thrown by circuitPlugin passes err instanceof CircuitOpenError for the root export. This holds for both the ESM and CommonJS builds.
- If
throwOnHttpErroris true (globally or per-request):- After all retries are exhausted, if the final response is any 4xx or 5xx status (e.g. 400, 401, 404, 429, 500, 503, etc.), the client throws an
HttpErrorcontaining the finalResponse.
- After all retries are exhausted, if the final response is any 4xx or 5xx status (e.g. 400, 401, 404, 429, 500, 503, etc.), the client throws an
- If
throwOnHttpErroris false (default):- The client returns the final
Responseobject, regardless of status.
- The client returns the final
- This applies to both global and per-request settings. Per-request overrides global.
- If a network error (e.g., lost connection, DNS failure) occurs and all retries are exhausted, the client throws a
NetworkError. - This happens regardless of the
throwOnHttpErrorflag, and even when an earlier attempt returned a response (for example a retried503). The earlier response is discarded and its body is cancelled; it is not returned as a fallback. - The failure is recognized in both native shapes: a browser message (
Failed to fetch,NetworkError when attempting to fetch resource.) or Node'sTypeError('fetch failed')whosecausecarries the operating system code (ECONNREFUSED,ENOTFOUND,EAI_AGAIN,ECONNRESET,ETIMEDOUT,UND_ERR_*, ...), including anAggregateErrorwith one entry per address tried. - A recognized network failure is retried like any other retryable failure, so
NetworkErroris thrown once the retries are exhausted.
- If the circuit breaker is open, the client throws a
CircuitOpenErrorbefore making the request. - This happens regardless of the
throwOnHttpErrorflag.
- If the request times out (exceeds the
timeoutvalue), the client throws aTimeoutError. - Native
fetchrejects a timed-out request with aDOMExceptionnamedTimeoutError, notAbortError, andffetchclassifies that rejection as aTimeoutErrortoo. - This happens regardless of the
throwOnHttpErrorflag.
- If the request is aborted by the user (via
AbortController), the client throws anAbortError. - Native
fetchrejects with the abort reason of the signal it was given: aDOMExceptionnamedAbortErrorby default, or the value passed toabort(reason), which may be any value - including a plainErroror a string. Any rejection while a request signal is aborted is classified as anAbortError. - A cancellation is never retried, so
retriesdoes not delay the rejection. - This happens regardless of the
throwOnHttpErrorflag.
- If all retries are exhausted and the error is not one of the above, the client throws a
RetryLimitError. - This happens regardless of the
throwOnHttpErrorflag.
- If a
transformResponseorafterhook throws, that error propagates to the caller unchanged. The original response is not returned and no HTTP fallback is applied. - Errors thrown by other core hooks follow the same rule: they are never converted into
HttpError,NetworkError, orRetryLimitError. - A hook a plugin provides is treated the same way. A plugin that refuses a request by throwing (
preRequest,beforeAttempt, orwrapDispatch) sends its own error to the caller, and only an attempt that actually failed becomes aRetryLimitError. Whether a refusal is attempted again is the retry policy's decision, not the core's. - The core tracks which stage raised the error it reports and publishes it as
ctx.metadata.provenance, so a plugin can tell the attempt's own failure from local code without reading the error's type. An error a hook throws is reported as local code even when it is one of the core error types.
fetch does not use a single error shape, so ffetch classifies a rejection from the signals it handed to the request and from the rejection itself:
| Native rejection | Recognized as |
|---|---|
AbortSignal.timeout() fires -> a DOMException named TimeoutError |
TimeoutError (onTimeout) |
A request signal is aborted -> the abort reason verbatim (a DOMException named AbortError, an Error, or a string) |
AbortError (onAbort) |
TypeError('fetch failed') whose cause carries a network code (ECONNREFUSED, ENOTFOUND, EAI_AGAIN, ECONNRESET, ETIMEDOUT, UND_ERR_*, ...) or an AggregateError of them |
NetworkError |
TypeError with a browser message (Failed to fetch, NetworkError when attempting to fetch resource., Safari's Load failed, ...) |
NetworkError |
The library's own AbortError or TimeoutError, or any other rejection carrying one of those names (a custom fetchHandler, another HTTP client) |
The library error as-is, otherwise normalized to TimeoutError/AbortError |
| Anything else | Left untouched -> RetryLimitError after the retries |
Recognized cancellations and timeouts are never retried, because the aborted signal would only cancel the retry as well. Recognized network failures are retried like any other retryable failure.
const client = createClient({ throwOnHttpError: true, retries: 1 })
// Throws HttpError for 404 after retries
await client('https://example.com/404') // throws HttpError
// Returns Response for 404 if flag is false
const client2 = createClient({ throwOnHttpError: false })
const res = await client2('https://example.com/404') // res.status === 404
// Throws TimeoutError
const client3 = createClient({ timeout: 10 })
await client3('https://example.com/slow') // throws TimeoutError
// Throws AbortError
const controller = new AbortController()
controller.abort()
await client('https://example.com', { signal: controller.signal }) // throws AbortError
// Native shapes are classified the same way
await client('https://example.com/slow') // throws TimeoutError (onTimeout fires)
const ui = new AbortController()
ui.abort('cancelled by the user interface')
await client('https://example.com', { signal: ui.signal }) // throws AbortError (onAbort fires)- The
throwOnHttpErrorflag can be set globally (on the client) or per-request (in theinitobject). Per-request always takes precedence. - Only the final response after all retries is considered for throwing
HttpError. - All other error types (timeout, abort, network, circuit, retry limit) are always thrown as errors, regardless of the flag.
- The last response of a retry chain is never returned as a fallback: if the final attempt fails with a transport error, that error is thrown even when earlier attempts produced a response.
- A rejection is only reclassified when it is identified as a cancellation or as a transport failure. Every other error keeps its own type and message and surfaces as a
RetryLimitErroronce the retries are exhausted. This is about the request's own rejection: an error raised by a hook or by a plugin is never converted into aRetryLimitError.