Skip to content

Errors ​

Implements: ARCH-07 (typed public errors with guards), DOC-01 (one canonical errors reference). Requirement text lives in CHARTER.md, Part 2.

What the library throws, how to tell the cases apart, which ones are worth retrying, and what the pipeline does before an error reaches you. Where the pipeline stages sit is described in ARCHITECTURE.md; this guide is about the values that come out of it.


Hierarchy ​

Error
├── EsiError                      statusCode · url (sanitised) · requestId · retryable
│   ├── TimeoutError              + timeoutMs
│   ├── EsiNetworkError           + cause
│   ├── EsiValidationError        + validationError · direction
│   ├── CircuitOpenError          + endpoint · failures · retryAfterMs
│   └── EsiFaultError             + code · cause                            (raised by the library, not ESI)
│       ├── EsiConfigurationError   VALIDATION_ERROR · NO_AUTH_TOKEN · CONFIGURATION_ERROR
│       ├── EsiParseError           JSON_PARSE_ERROR
│       ├── EsiPaginationError      PAGINATION_INCOMPLETE
│       └── EsiTokenRefreshError    TOKEN_REFRESH_FAILED
├── AuthError                                                               (token manager, SSO)
│   ├── SsoError                  statusCode · errorCode · errorDescription
│   ├── TokenRevokedError         characterId?
│   ├── TokenDecodeError
│   └── CharacterNotFoundError    characterId
├── SdeError                                                                (offline SDE module)
│   ├── SdeDatabaseError          cause   (deprecated)
│   ├── SdeValidationError        validationError · entityType · entityId?
│   └── SdeVersionMismatchError   expected · actual

Every failure the request pipeline raises is an EsiError, so isEsiError(err) is true for all of them and safe mode keeps each one's class. The auth and SDE families are raised outside the pipeline and keep their own bases.

Every field listed is readonly. Every class sets name to its own class name, so err.name survives serialisation even where instanceof does not.

EsiError ​

Raised for any HTTP-level failure, and the base of every other class the request pipeline raises. Source: src/core/util/error.ts.

MemberTypeMeaning
statusCodenumberHTTP status. 0 when no status exists (timeout, network, validation, circuit, fault).
messagestringStatus text (HTTP <status> when neither the client nor the server names the status), with remediation appended for 401 and 403.
urlstring | undefinedRequest URL passed through sanitizeUrl. Absent on rate-limiter, circuit and configuration errors.
requestIdstring | undefinedThe X-Esi-Request-Id response header, when ESI sent one. Quote it when reporting to CCP.
retryableboolean (getter)true for 420, 429, 502, 503, 504, timeouts and network failures. Subclasses that cannot succeed on retry override it.
isRateLimited()methodstatusCode is 420 or 429.
isNotFound()methodstatusCode is 404.
isUnauthorized()methodstatusCode is 401.
isForbidden()methodstatusCode is 403.
isServerError()methodstatusCode is 500 or above.
isTimeout()methodtrue for a TimeoutError, false for every other subclass. Prefer the isTimeout guard.

TimeoutError extends EsiError ​

Raised when the AbortController timer fires before the response has fully arrived: before the headers, or while the body is still being read. statusCode is 0, timeoutMs is the limit that expired, and the message reads Request timed out after <n>ms. The default limit is 30 000 ms, set with the timeout option on EsiClient or ApiClient.setTimeout().

EsiValidationError extends EsiError ​

Raised when a body fails its Zod schema. See RUNTIME-VALIDATION.md for when validation runs.

FieldTypeMeaning
validationErrorunknownThe ZodError from safeParse. Typed unknown so Zod is not in the public signature.
direction'request' | 'response'request for requestSchema failures (opt-in validateRequest), otherwise response.
statusCode0Always 0. .retryable and isTimeout() are false: the same request returns the same body.

EsiNetworkError extends EsiError ​

Raised when a request fails below HTTP for a reason other than the timeout: a refused connection, a failed DNS lookup, TLS, or a reset before the headers or part way through the body. statusCode is 0, .retryable is true, the message reads Network request failed: <reason>, and the underlying error is on cause. isTimeout() is false; a timeout is a TimeoutError.

CircuitOpenError extends EsiError ​

Raised by the circuit breaker before the request is sent, when the breaker for that endpoint is open or its half-open probe slot is taken. statusCode is 0 and .retryable is false: the client never retries it, and the caller should wait retryAfterMs. url is not set; endpoint names the breaker key.

FieldTypeMeaning
endpointstringThe breaker key, which is the resolved request path.
failuresnumberConsecutive failures recorded when the breaker opened.
retryAfterMsnumberTime until the breaker will admit a half-open probe.

The breaker is off unless enableCircuitBreaker is set. Its thresholds are covered in ARCHITECTURE.md.

EsiFaultError extends EsiError ​

The base of the failures the library raises itself rather than ESI. statusCode is 0, .retryable and isTimeout() are false, code is one of the EsiFaultCode values below, and the message starts with [<code>] . cause holds the error that led to it, where there was one. An EsiFaultError with code ESIJS_ERROR wraps anything unexpected that reached the pipeline's final catch (a throwing interceptor, for example); the original is on cause.

ClasscodeRaised when
EsiConfigurationErrorVALIDATION_ERROR, NO_AUTH_TOKEN, CONFIGURATION_ERRORThe client's setup or a call's arguments are wrong. See Configuration faults.
EsiParseErrorJSON_PARSE_ERRORA response body is not the JSON it should be. url is the request URL; the parser's error is on cause.
EsiPaginationErrorPAGINATION_INCOMPLETEA page walk stopped before it held the whole collection. See Incomplete pagination.
EsiTokenRefreshErrorTOKEN_REFRESH_FAILEDThe token provider threw during a 401 refresh. What it threw (a TokenRevokedError, an SsoError) is on cause.

Incomplete pagination ​

EsiPaginationError is how a collecting path (the eager domain method, fetchAll*, stream*, the cursor handler) says it does not hold the whole collection (DES-08, PAGINATION.md). The URL is sanitised in url and in the message; a page error that is not itself an EsiError is on cause.

When it is thrown depends on the incompletePages option. A page that fails with a non-ESI error (fetch-failed) always throws. The other three reasons, page-cap, empty-page and cursor-cap, throw only when the client is built with incompletePages: 'throw'. Under the default, 'truncate', those walks return the pages fetched so far and log a warning naming the option, as every 12.x release has. The default becomes 'throw' in a future major release. Opt in now on either construction surface: createEsi({ userAgent, incompletePages: 'throw' }) or new EsiClient({ incompletePages: 'throw' }).

FieldTypeMeaning
reasonPaginationStopReasonWhy the walk stopped: page-cap, empty-page, cursor-cap or fetch-failed (below).
endpointstringThe endpoint path the walk started from, sensitive query parameters redacted.
reportedPagesnumber | undefinedThe page count page 1 reported in X-Pages; undefined on a cursor walk, which has none.
fetchedPagesnumberPages read in order before the stop, the page it stopped at included.
partialreadonly unknown[]The items collected before the stop, in page order, frozen. Empty on a stream, whose pages were already yielded to you.
itemCountnumberThe items delivered before the stop: partial.length, or on a stream the number of items it yielded.
reasonRaised when
page-capWith incompletePages: 'throw': page 1 reported more than 1000 pages, the most the client requests for one call (security model P8). No further page is requested; partial holds page 1.
empty-pageWith incompletePages: 'throw': a page after the first that page 1 reported came back with no items. The collection shrank during the walk, so items moved onto pages already read and were skipped. The last page is included.
cursor-capWith incompletePages: 'throw': the cursor handler reached its page limit while the last page still carried an after cursor.
fetch-failedIn either mode: a page failed with an error that is not itself an EsiError (a throwing interceptor, for example). An EsiError from a page is rethrown as it is rather than wrapped.

The error is not retryable, and none of these reasons is fixed by retrying at once: empty-page means the collection changed, so call again later for a consistent snapshot, or keep partial knowing it may miss items.

typescript
import { EsiClient, isPaginationError } from '@lgriffin/esi.ts';
import type { MarketOrder } from '@lgriffin/esi.ts';

const esi = new EsiClient({ clientId: 'my-app', incompletePages: 'throw' });

async function forgeOrders(): Promise<{
  orders: MarketOrder[];
  complete: boolean;
}> {
  try {
    return {
      orders: await esi.market.getMarketOrders(10000002),
      complete: true,
    };
  } catch (err) {
    if (isPaginationError(err) && err.reason === 'empty-page') {
      // The order book moved mid-walk. Use what arrived, marked incomplete,
      // and fetch again on the next cycle.
      return { orders: err.partial as MarketOrder[], complete: false };
    }
    throw err;
  }
}

Auth family ​

Raised by EveSsoClient and EsiTokenManager, not by the request pipeline directly. Source: src/auth/errors.ts.

ClassFieldsRaised when
AuthError—Base class; not thrown on its own.
SsoErrorstatusCode, errorCode, errorDescription?EVE SSO answered a token or revoke call with non-2xx. errorCode is the OAuth2 error field or unknown. isRetryable() is true for 429 and 5xx.
TokenRevokedErrorcharacterId?SSO returned invalid_grant, or the manager already recorded the revocation. Re-login is the only fix.
TokenDecodeError—An access token could not be decoded into the JWT claims the manager needs.
CharacterNotFoundErrorcharacterIdNo token is stored for the character.

SsoError.isRetryable() is a method; EsiError.retryable is a getter. The two families were written at different times and have not been aligned.

SDE family ​

Raised by the offline Static Data Export module, which shares no code with the HTTP pipeline. Exported from @lgriffin/esi.ts/sde only. Source: src/sde/errors.ts.

ClassFieldsRaised when
SdeError—Base class.
SdeDatabaseErrorcauseNever: deprecated, removed in the next major (see below).
SdeValidationErrorvalidationError, entityType, entityId?A stored row failed its schema.
SdeVersionMismatchErrorexpected, actualThe loaded SDE build is not the one the caller asked for.

SdeDataProvider.fromDirectory and fromZip also throw a plain SdeError when the call needs js-yaml or adm-zip, the optional peer dependencies of ./sde, and it is not installed. The message names the package and its npm install command.

SdeDatabaseError and isSdeDatabaseError are deprecated. The SQLite database builder was the only code that threw the class, and it was removed with the optional better-sqlite3 peer after 12.2.0, so nothing in the package raises it any more. Both stay exported from ./sde and ./sde/memory with the same shape, so an existing instanceof check or guard call still compiles, and both are removed in the next major release.


Type guards and where to import them ​

Every guard takes unknown and narrows. They are instanceof checks, so a guard only matches errors created by the same copy of the package; a duplicated dependency in node_modules breaks them. The root entry and ./errors export the same classes and guards, so an error thrown by the client matches a guard or class imported from either. The CommonJS and ES module builds are separate copies, though: a guard loaded with require does not match an error thrown by a client loaded with import, or the other way round.

GuardNarrows toTrue when. (root)./errors./sde
isEsiErrorEsiErrorany EsiError or subclass●●·
isNotFoundEsiErrorstatus 404●●·
isUnauthorizedEsiErrorstatus 401●●·
isForbiddenEsiErrorstatus 403●●·
isRateLimitedEsiErrorstatus 420 or 429●●·
isServerErrorEsiErrorstatus >= 500●●·
isTimeoutTimeoutErrorinstanceof TimeoutError●●·
isRetryableEsiError.retryable is true●●·
isValidationErrorEsiValidationErrorinstanceof EsiValidationError●●·
isCircuitOpenCircuitOpenErrorinstanceof CircuitOpenError●●·
isNetworkErrorEsiNetworkErrorinstanceof EsiNetworkError●●·
isFaultErrorEsiFaultErrorany library fault●●·
isConfigurationErrorEsiConfigurationErrorinstanceof EsiConfigurationError●●·
isParseErrorEsiParseErrorinstanceof EsiParseError●●·
isPaginationErrorEsiPaginationErrorinstanceof EsiPaginationError●●·
isTokenRefreshErrorEsiTokenRefreshErrorinstanceof EsiTokenRefreshError●●·
isAuthErrorAuthErrorany auth-family error●●·
isSsoErrorSsoErrorinstanceof SsoError●●·
isTokenRevokedTokenRevokedErrorinstanceof TokenRevokedError●●·
isCharacterNotFoundCharacterNotFoundErrorinstanceof CharacterNotFoundError●●·
isSdeErrorSdeErrorany SDE-family error··●
isSdeDatabaseErrorSdeDatabaseErrorinstanceof SdeDatabaseError (deprecated)··●
isSdeValidationErrorSdeValidationErrorinstanceof SdeValidationError··●
isSdeVersionMismatchSdeVersionMismatchErrorinstanceof SdeVersionMismatchError··●

All the classes follow the same pattern as their guards. sanitizeUrl and the ValidationDirection and EsiFaultCode types are exported from both . and ./errors. A test (tests/tdd/core/errorsEntryParity.test.ts) fails if the root exports an error class or guard that ./errors does not.

typescript
import { EsiError, isNotFound, isCircuitOpen } from '@lgriffin/esi.ts/errors';

Retryability ​

.retryable and isRetryable describe the status code only. Whether the library actually retries also depends on the HTTP method and the retry configuration.

Condition.retryableRetried by RetryStrategy
TimeoutError (status 0)yesyes, for GET or when retryMutations is set
420, 429yesyes, for GET or when retryMutations is set
502, 503, 504yesyes, for GET or when retryMutations is set
500 and other 5xxnono; a cached copy is served instead where one exists
401 with a token providernorefreshed once, then the request is replayed
304 with no cached bodynono
other 4xxnono
CircuitOpenErrornonever; rethrown immediately so the breaker is not hammered
EsiValidationErrornono; validation runs after the retry loop has returned
EsiNetworkError (DNS, reset, TLS)yesyes, for GET or when retryMutations is set
EsiFaultError and subclassesnono

The defaults set by EsiClient are three retries, 1 s base delay and a 30 s cap, with exponential backoff and 0.75–1.25× jitter. retryAttempts: n changes only the count; retryConfig replaces the whole object; retryStrategy replaces the implementation. A RetryStrategy constructed by hand with no config performs zero retries.

POST, PUT and DELETE are not retried unless retryConfig.retryMutations is true, because a retried mutation can apply twice.

Before 11.0.0, .retryable was true for every status-0 error, including EsiValidationError and anything safe mode converted, and isTimeout() was true for them too. Both now follow the class: only TimeoutError and EsiNetworkError among the status-0 classes are retryable, and only TimeoutError reports isTimeout().


The 401 refresh path ​

Configure a token provider with onTokenRefresh on EsiClient, ApiClient.setTokenProvider(), or EsiTokenManager.createClient() (deprecated in 11.0.0), which wires tokenProviderFor(characterId) for you. On the 11.0 client, esi.as(tokens.identity(characterId)) refreshes through the manager with no provider to configure.

  1. A request to an endpoint whose definition has requiresAuth: true returns 401.
  2. RetryStrategy sees an EsiError with status 401, a provider, and no refresh yet in this call. It calls the provider.
  3. Concurrent refreshes on the same ApiClient share one in-flight promise, so a burst of 401s triggers one SSO call.
  4. The new token replaces the stored one and the request is replayed. The refresh does not consume a retry attempt.
  5. If the replay returns 401 again, that error is thrown. There is one refresh per call.

If the provider throws, what you receive depends on what it threw:

Provider throwsYou receive
an EsiError, including CircuitOpenErrorthat error, unchanged
anything else, including TokenRevokedError and SsoErrorEsiTokenRefreshError ([TOKEN_REFRESH_FAILED] Token refresh failed: <message>), the original on cause

To act on a revoked token, test the cause:

typescript
import { isTokenRefreshError, isTokenRevoked } from '@lgriffin/esi.ts/errors';

function needsLogin(err: unknown): boolean {
  return isTokenRefreshError(err) && isTokenRevoked(err.cause);
}

Without a provider, or on an endpoint without requiresAuth, a 401 is thrown straight away.


Status handling before an error is thrown ​

Some statuses never become errors, and some errors carry extra text.

ResponseOutcome
201Returned with the parsed body, or undefined if the body is not JSON.
204Returned as undefined.
304, cached body presentCached body returned; metadata cacheHitType: 'etag-304'.
304, no cached bodyEsiError(304, 'Not Modified — no cached data available'). Happens only if the cache was cleared between request and response.
5xx, cached copy presentStale body returned instead of an error; metadata stale: true, cacheHitType: 'stale-on-error', original status kept. Not retried.
5xx, no cached copyEsiError with the status; retried if 502, 503 or 504.
420, 429EsiError, logged as a warning; the rate limiter blocks the group before the next call.
401EsiError with remediation appended (below).
403EsiError with remediation appended (below).

A stale response is only possible for a GET whose earlier success carried an ETag, since only those are cached, and only while that entry is kept: one hour past its freshness TTL, or the cache's defaultTtl when the response gave no TTL (see ARCHITECTURE.md). Use withMetadata() to see meta.stale.

The remediation text is appended to the status message on the main request path:

  • 401: Unauthorized — the access token was missing, expired, or lacks the required ESI scope. Fix: verify ESI_ACCESS_TOKEN, or configure onTokenRefresh for automatic refresh on 401
  • 403: Forbidden — your access token does not have the OAuth scopes required for this endpoint. Check the scopes on your EVE SSO application

Page requests made by the pagination handlers carry the plain status text without remediation.

When the response body is ESI's { "error": "<reason>" }, the reason follows the status text on both paths, capped at 200 characters: Forbidden: token is expired — your access token does not have …. Any other body adds nothing.

The rate limiter can also raise an EsiError(429, "Rate limit group '<group>' still blocked for <n>s") without sending anything, when a group stays blocked beyond its wait budget. It has no url and is retryable.


Configuration faults ​

EsiConfigurationError covers a client that is set up wrong or called with bad arguments. None is retried, and none reaches ESI.

CodeRaised byMessage begins
VALIDATION_ERRORclient constructionInvalid base URL, Base URL must use HTTPS protocol, Base URL host '<h>' is not in the allowlist
VALIDATION_ERRORpath and query parameter buildingPath parameter '<p>' must not be empty / contains invalid characters / must be a finite number; Query parameter '<p>' must not be null or undefined / must be a finite number / exceeds maximum length
NO_AUTH_TOKENheader buildingAuthorization header is required for this endpoint but no access token is configured
CONFIGURATION_ERRORdependency resolutionNo rate limiter configured on ApiClient

Each message starts with its code once, [NO_AUTH_TOKEN] Authorization header is required …. Before 11.0.0 these were plain Errors, and a fault raised inside a request was wrapped a second time as [ESIJS_ERROR] [NO_AUTH_TOKEN] …; the pipeline now passes every EsiError through unchanged. Branch on the class or on code, not the message:

typescript
import { isConfigurationError } from '@lgriffin/esi.ts/errors';

function isMissingToken(err: unknown): boolean {
  return isConfigurationError(err) && err.code === 'NO_AUTH_TOKEN';
}

A 200 with an empty body is an EsiParseError on every endpoint except two. getPublicContractItems and getPublicContractBids resolve with [] when ESI sends no content: a 204, or a 200 with Content-Length: 0. ESI answers that way for a public contract that has expired or been accepted. The page helpers read it the same way: fetchAllPublicContractItems and fetchAllPublicContractBids resolve with [], and streamPublicContractItems and streamPublicContractBids (or streamEndpoint and fetchAllEndpoint by name) yield one empty page. On the createEsi() views, contracts.public.items(id).get() and contracts.public.bids(id).get() yield nothing in the same case: the generator marks every GET whose spec pairs a JSON 200 holding an array (after resolving $ref) with a 204 (OperationMeta.emptyWhenNoContent), and those are the two. An object-valued route with a 204 is not marked, so its no-content answer stays undefined. Both surfaces read the answer through one check in src/core/requestPipeline/statusHandling.ts, and tests/tdd/core/endpointOperationParity.test.ts fails if the endpoint definitions and the generated operations flag different routes.

Two plain errors carry no code: No token provider configured, from calling ApiClient.refreshToken() directly without a provider, and At least one client type must be specified, from building an empty EsiClientBuilder. Both are raised outside a request.

A page failure during offset pagination that is itself an EsiError is rethrown as that error; only other failures become an EsiPaginationError with reason fetch-failed. With incompletePages: 'throw', a walk that cannot be complete (a page count above the limit, an empty page, a cursor still set at the limit) is an EsiPaginationError too; by default it is truncated with a warning. See Incomplete pagination. Cursor and streaming failure semantics are covered in PAGINATION.md.


sanitizeUrl ​

Every EsiError passes its URL through sanitizeUrl in the constructor, and EsiValidationError sanitises the URL in its message too. The function is exported so you can apply the same rule to your own logs.

typescript
import { sanitizeUrl } from '@lgriffin/esi.ts/errors';

sanitizeUrl('https://esi.evetech.net/latest/x/?token=abc&page=2');
// 'https://esi.evetech.net/latest/x/?token=%5BREDACTED%5D&page=2'
  • The value of each of these query parameters is replaced with [REDACTED] (URL-encoded in the output): token, access_token, api_key, refresh_token, client_secret, code, key, secret, auth, password, bearer. Matching is exact and case-sensitive.
  • A string that new URL() cannot parse keeps everything before ? and replaces the query with ?[params-redacted].
  • undefined and the empty string are returned unchanged.

Every class in the hierarchy is an EsiError, so every url field is sanitised; EsiPaginationError sanitises the URL in its message as well. ESI.ts sends tokens in the Authorization header rather than the query string, so this matters only for URLs you construct yourself. The runtime controls behind this are listed in SECURITY.md.


Safe mode ​

withSafeMode() on any domain client returns the same methods, resolving to a discriminated union instead of throwing:

typescript
import type { EsiError, EsiResponseMeta } from '@lgriffin/esi.ts';

type EsiResult<T> =
  | { ok: true; data: T; meta: EsiResponseMeta }
  | { ok: false; error: EsiError; meta?: EsiResponseMeta };
typescript
import { EsiClient, createConsoleLogger } from '@lgriffin/esi.ts';

const log = createConsoleLogger('info');
const esi = new EsiClient({ clientId: 'my-app' });

const result = await esi.characters
  .withSafeMode()
  .getCharacterPublicInfo(2112625428);

if (result.ok) {
  log.info(result.data.name, { fromCache: result.meta.fromCache });
} else if (result.error.isNotFound()) {
  log.info('No such character');
} else {
  log.error('Lookup failed', {
    status: result.error.statusCode,
    error: result.error,
  });
}

Every failure is delivered as the EsiError the throwing API would have raised, with its class intact: a CircuitOpenError keeps retryAfterMs, an EsiConfigurationError keeps its code. Anything that is not an EsiError arrives as an EsiFaultError with code ESIJS_ERROR and the original on cause. meta is not populated on failure in the current implementation.

client.withSafeMode() on EsiClient or CustomEsiClient returns every domain client's safe-mode view from one object: await client.withSafeMode().status.getStatus() resolves the same envelope as client.status.withSafeMode().getStatus().

On the spec-shaped createEsi() surface, esi.withSafeMode() gives both views the same treatment: every single-answer operation resolves OperationResult<T> ({ ok: true, data, meta } or { ok: false, error }, from @lgriffin/esi.ts/client), and the refusal of an authenticated operation on the public view arrives as an EsiConfigurationError coded NO_AUTH_TOKEN instead of a rejection. A paginated operation still yields items and rejects on a failing page.

On a value already typed EsiError, prefer the instance methods (result.error.isNotFound()) or instanceof with the class. The guards take unknown, so they narrow here as well: isCircuitOpen(result.error) narrows to CircuitOpenError.


Order matters: test the specific cases before the broad ones, and test isValidationError before isRetryable.

typescript
import {
  EsiClient,
  isCircuitOpen,
  isValidationError,
  isTimeout,
  isRateLimited,
  isNotFound,
  isUnauthorized,
  isForbidden,
  isRetryable,
  isConfigurationError,
  isTokenRefreshError,
  isEsiError,
} from '@lgriffin/esi.ts';

const esi = new EsiClient({ clientId: 'my-app' });

try {
  const orders = await esi.market.getMarketOrders(10000002);
  // …
} catch (err) {
  if (isCircuitOpen(err)) {
    // Breaker tripped; back off for err.retryAfterMs.
  } else if (isValidationError(err)) {
    // ESI changed shape. err.direction, err.validationError; report it, do not retry.
  } else if (isTimeout(err)) {
    // Already retried by the client; err.timeoutMs.
  } else if (isRateLimited(err)) {
    // Already retried; the limiter is blocking the group.
  } else if (isNotFound(err)) {
    // Expected for deleted or hidden entities.
  } else if (isUnauthorized(err) || isForbidden(err)) {
    // Token or scope problem; the message says which.
  } else if (isTokenRefreshError(err)) {
    // The provider could not refresh; err.cause says why (isTokenRevoked: log in again).
  } else if (isConfigurationError(err)) {
    // A bug in the calling code or its setup; err.code says which.
  } else if (isRetryable(err)) {
    // 502/503/504 or a network failure that outlasted the retry budget.
  } else if (isEsiError(err)) {
    // Any other failure; log err.statusCode, err.url, err.requestId.
  } else {
    // Not from the client.
    throw err;
  }
}

By the time an error reaches this block, the client has already retried what it considers retryable. Retrying again in application code multiplies the backoff; prefer raising retryConfig.maxRetries if the default budget is too small.

Log output for the same failures is described in LOGGING.md. Tests for this behaviour sit under tests/tdd/core/ and the resilience features in tests/bdd/; see TESTING.md.

Released under the GPL-3.0-or-later license.