Skip to content

Latest commit

 

History

History
129 lines (92 loc) · 10.8 KB

errorhandling.md

File metadata and controls

129 lines (92 loc) · 10.8 KB

Error Handling in ffetch

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:

Summary Table

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

Error Identity Across Entrypoints

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.

Detailed Behavior

1. HTTP Errors (all 4xx and 5xx)

  • If throwOnHttpError is 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 HttpError containing the final Response.
  • If throwOnHttpError is false (default):
    • The client returns the final Response object, regardless of status.
  • This applies to both global and per-request settings. Per-request overrides global.

2. Network Errors

  • 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 throwOnHttpError flag, and even when an earlier attempt returned a response (for example a retried 503). 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's TypeError('fetch failed') whose cause carries the operating system code (ECONNREFUSED, ENOTFOUND, EAI_AGAIN, ECONNRESET, ETIMEDOUT, UND_ERR_*, ...), including an AggregateError with one entry per address tried.
  • A recognized network failure is retried like any other retryable failure, so NetworkError is thrown once the retries are exhausted.

3. Circuit Breaker

  • If the circuit breaker is open, the client throws a CircuitOpenError before making the request.
  • This happens regardless of the throwOnHttpError flag.

4. Timeout

  • If the request times out (exceeds the timeout value), the client throws a TimeoutError.
  • Native fetch rejects a timed-out request with a DOMException named TimeoutError, not AbortError, and ffetch classifies that rejection as a TimeoutError too.
  • This happens regardless of the throwOnHttpError flag.

5. Abort

  • If the request is aborted by the user (via AbortController), the client throws an AbortError.
  • Native fetch rejects with the abort reason of the signal it was given: a DOMException named AbortError by default, or the value passed to abort(reason), which may be any value - including a plain Error or a string. Any rejection while a request signal is aborted is classified as an AbortError.
  • A cancellation is never retried, so retries does not delay the rejection.
  • This happens regardless of the throwOnHttpError flag.

6. Retry Limit

  • If all retries are exhausted and the error is not one of the above, the client throws a RetryLimitError.
  • This happens regardless of the throwOnHttpError flag.

7. Hook Errors

  • If a transformResponse or after hook 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, or RetryLimitError.
  • A hook a plugin provides is treated the same way. A plugin that refuses a request by throwing (preRequest, beforeAttempt, or wrapDispatch) sends its own error to the caller, and only an attempt that actually failed becomes a RetryLimitError. 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.

8. Native Rejection Shapes

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.

Examples

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)

Notes

  • The throwOnHttpError flag can be set globally (on the client) or per-request (in the init object). 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 RetryLimitError once 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 a RetryLimitError.