Skip to content

ESI.ts Architecture ​

Implements: ARCH-01 · ARCH-02 · ARCH-03 · ARCH-04 · ARCH-05 · ARCH-06 · ARCH-07 · ARCH-08 · ARCH-09 — see CHARTER.md Part 2.

How ESI.ts is layered, the route every request takes, and how each piece of middleware behaves. The charter states the requirements; this guide explains how the code meets them. Where the two disagree, the code is the fact and the difference is called out.

This guide describes the code on master at 10.2.3 with ROADMAP Phase 2 merged. The seams it added (ports, PipelineTransport, the generated operations and the scope tree) reach consumers through the ./client entry: one shared runtime, a public view and a view per identity (MULTI-CHARACTER.md); createMockTransport in ./testing implements the HttpTransport port for consumers' tests. §1a describes them and says what 11.0.0 will still change: the Phase 4 logger work, as planned in ROADMAP.md.

Topics with their own guide are summarised here and linked:

TopicCanonical guide
Naming, endpoint definitions, adding a clientDESIGN-RULES.md
Error classes, guards, retryability, safe modeERRORS.md
ILogger, per-client loggers, levelsLOGGING.md
Offset and cursor pagination, stream*, fetchAll*PAGINATION.md
Zod schemas and validation optionsRUNTIME-VALIDATION.md
Runtime defences and supply chainSECURITY.md
Test tiersTESTING.md
The SDE module and how it complements the clientSDE.md
CI workflows and gatesQUALITY-GATES.md
ReleasesRELEASE.md
The 11.0 plan, phase by phaseROADMAP.md
Major, minor or patch; breaking-change markersSEMVER.md

C4 Model ​

C4 Level 1 — System Context ​

System context showing ESI.ts in its operating environment.

ElementDescription
Consumer ApplicationNode.js application that needs EVE Online data: Node 22 or newer from 11.0.0 (engines, REL-05); 10.x supports Node 18 and 20
ESI.tsTypeScript SDK — auth, caching, rate limiting, circuit breaking, pagination, validation
EVE Online ESI APICCP's REST API at esi.evetech.net, secured by EVE SSO (OAuth2)
EVE SSOOAuth2 authorisation server — issues and refreshes access tokens
ESI OpenAPI SpecMachine-readable API spec used at build time for code generation. The generated operations read a copy vendored at the compatibility date (tests/contract/snapshots/esi-openapi.snapshot.json); generate:types reads the live document
mermaid
flowchart TB
    consumer(["Consumer App"])

    subgraph boundary [" "]
        esits["ESI.ts SDK"]
    end

    esi[/"ESI API"/]
    sso[/"EVE SSO"/]
    spec[/"OpenAPI Spec"/]

    consumer -- "TypeScript API" --> esits
    esits -- "HTTPS + JSON" --> esi
    esits -- "OAuth2: PKCE code exchange, refresh, revoke" --> sso
    spec -. "Build-time codegen" .-> esits

    style consumer fill:#08427b,color:#fff,stroke:#073b6f
    style esits fill:#1168bd,color:#fff,stroke:#0e5aa7
    style esi fill:#999,color:#fff,stroke:#888
    style sso fill:#999,color:#fff,stroke:#888
    style spec fill:#999,color:#fff,stroke:#888
    style boundary fill:none,stroke:#1168bd,stroke-width:2px,stroke-dasharray:5

C4 Level 2 — Container Diagram ​

Five layers on one request path, auth beside them, the Phase 2 seams beneath them, and side modules that share nothing with the HTTP pipeline.

ContainerWherePurpose
Constructionsrc/EsiClient.ts, src/EsiClientBuilder.tsEsiClient, EsiClientBuilder → CustomEsiClient, EsiApiFactory. All three call configureApiClient(). ApiClientBuilder (src/core/ApiClientBuilder.ts) builds a bare ApiClient and does not (§9)
Domain clientssrc/clients/ (one class per ESI domain + BaseEsiClient)Named methods, stream* and fetchAll* wrappers, withMetadata() and withSafeMode() views. No HTTP knowledge
Endpoint definitionssrc/core/endpoints/*Endpoints.tsDeclarative maps of path, method, auth, pagination kind and schemas. createClient() turns a map into typed methods
Schemassrc/schemas/Hand-written Zod schemas for runtime validation; return types are inferred from them
Request pipelinesrc/core/ApiRequestHandler.ts, src/core/requestPipeline/Pure functions: cache policy, headers, fetch execution, status handling, pagination, middleware bridge, dependency resolution
Resiliencesrc/core/ (RetryStrategy, RateLimiter, CircuitBreaker, RequestDeduplicator, ETagCacheManager)Each behind an interface with a setter on ApiClient
TransportApiClient.getFetch()globalThis.fetch unless replaced with setFetch(). Timeout by AbortController
Generated metadatasrc/types/generated/, src/core/endpoints/esi-*.generated.tsTypes, cache TTLs, rate-limit groups and scopes generated from the ESI OpenAPI spec by generate:types; CI verifies freshness
Generated operationssrc/generated/operations.generated.ts235 typed operations (one *Meta and one function each), ScopeTree, PublicScopeTree and createScopeTree(transport), generated from the vendored spec by spec:generate. Imports only the ports. ScopeTree and PublicScopeTree are exported from ./client (§1a)
Portssrc/core/ports/Seven type-only interfaces: CacheStore, Clock, HttpTransport, Identity, Logger, OperationTransport, TokenProvider. They import nothing. Exported from ./client (§1a)
Adapterssrc/adapters/PipelineTransport implements OperationTransport over handleRequest and fetchPages, so a generated operation shares the ApiClient's budgets and cache. Internal; ./client wraps it (§1a)
Authsrc/auth/EveSsoClient (authorisation URL with PKCE, code exchange, refresh, revoke), EsiTokenManager (per-character tokens, refresh, identity, tokenProviderFor, createClient), MemoryTokenStorage and FileTokenStorage, the AuthError subtree. Exported from the root and ./errors
Side modules./schemas, ./errors, ./testing, ./client, ./sde, ./sde/memorySubpath entry points. The SDE module is an offline lookup layer with its own error hierarchy and shares no code with the pipeline; lint:layers enforces that in both directions and a bundle test keeps ./sde/memory free of file-system, YAML, ZIP and SQLite code (ARCH-10)
mermaid
flowchart TB
    consumer(["Consumer App"])

    subgraph esits ["ESI.ts Library"]
        direction TB
        publicApi["Construction"]
        domainClients["Domain Clients"]
        endpointDefs["Endpoint Defs"]
        schemas["Schemas"]
        pipeline["Request Pipeline"]
        resilience["Resilience"]
        transport["Transport"]
        generated["Generated metadata"]
        auth["Auth (SSO, tokens)"]
        ports["Ports"]
        adapters["PipelineTransport"]
        genOps["Generated operations"]
    end

    esi[/"ESI API"/]

    consumer --> publicApi
    publicApi --> domainClients
    domainClients --> endpointDefs
    endpointDefs --> schemas
    endpointDefs --> pipeline
    pipeline --> resilience
    pipeline --> transport
    pipeline -. "TTLs, groups" .-> generated
    transport -- "HTTPS" --> esi
    auth -- "TokenProvider" --> publicApi
    auth -- "HTTPS" --> sso[/"EVE SSO"/]
    genOps --> ports
    adapters -. "implements OperationTransport" .-> ports
    adapters --> pipeline

    style consumer fill:#08427b,color:#fff,stroke:#073b6f
    style publicApi fill:#1168bd,color:#fff,stroke:#0e5aa7
    style domainClients fill:#1168bd,color:#fff,stroke:#0e5aa7
    style endpointDefs fill:#1168bd,color:#fff,stroke:#0e5aa7
    style schemas fill:#1168bd,color:#fff,stroke:#0e5aa7
    style pipeline fill:#1168bd,color:#fff,stroke:#0e5aa7
    style resilience fill:#1168bd,color:#fff,stroke:#0e5aa7
    style transport fill:#1168bd,color:#fff,stroke:#0e5aa7
    style generated fill:#438dd5,color:#fff,stroke:#3c7fc0
    style auth fill:#1168bd,color:#fff,stroke:#0e5aa7
    style ports fill:#6a1b9a,color:#fff,stroke:#4a148c
    style adapters fill:#438dd5,color:#fff,stroke:#3c7fc0
    style genOps fill:#438dd5,color:#fff,stroke:#3c7fc0
    style sso fill:#999,color:#fff,stroke:#888
    style esi fill:#999,color:#fff,stroke:#888
    style esits fill:#e8e8e8,stroke:#aaa

C4 Level 3 — Component: Core Request Pipeline ​

ApiRequestHandler.ts coordinates; each module in src/core/requestPipeline/ has one responsibility and receives its dependencies as parameters (ARCH-03). dependencies.ts resolves the cache, rate limiter, circuit breaker and retry strategy. Two sites read a dependency from the ApiClient directly: handleRequest and invalidateAfterWrite both call client.getDeduplicator(), because a null deduplicator already means "no coalescing" and needs no resolution rule.

ComponentFileExports
handleRequestApiRequestHandler.tsSpec-TTL cache check, retry wrapping, a spec-TTL re-check on each retry attempt, deduplication keyed by buildDedupeKey; delegates to executeRequest
handleSinglePageRequestApiRequestHandler.tsOne page with retry and no If-None-Match, used by stream* and fetchAll* (see PAGINATION.md)
executeRequestApiRequestHandler.tsFetch; write invalidation on 201/204; the 201 body; emptyWhenNoContent; the 304 re-run; status handling; text or JSON body; cache write; pagination; response interceptors
dependenciesdependencies.tsresolveCache, resolveRateLimiter, resolveCircuitBreaker, resolveRetryStrategy
headersheaders.tsbuildRequestHeaders, parseCacheControlTtl
cachePolicycachePolicy.tslookupSpecTtl, trySpecAwareCacheHit, hasCachedEntry, tryStaleCacheResponse, cacheResponse, currentWriteGeneration, invalidateAfterWrite, evictRejectedResponse
statusHandlingstatusHandling.tshandleEarlyStatus (201/204/304), handleErrorResponse (4xx/5xx), readEsiErrorReason, statusMessage, STATUS_MESSAGES, wrapError
middlewareBridgemiddlewareBridge.tsapplyRequestMiddleware, applyResponseInterceptors
fetchExecutionfetchExecution.tsexecuteSingleFetch, fetchOnePage, parseJsonBody
paginationpaginationOrchestration.tshandleCursorPagination, handleOffsetPagination
cacheKeysrc/core/cache/cacheKey.tsbuildCacheKey (URL) and buildDedupeKey (endpoint), both prefixed with the caller's identity when the endpoint requires auth: the SSO character once ESI has accepted the token, else a hash of the Authorization header

statusMessage is exported from statusHandling.ts but not re-exported from requestPipeline/index.ts; everything else in the table is. createClient calls evictRejectedResponse when a body fails its schema, so a rejected body is not served again from the spec TTL or after a 304.

mermaid
flowchart TB
    subgraph coordinator ["ApiRequestHandler.ts"]
        direction LR
        handleReq["handleRequest"]
        handleSingle["handleSinglePage"]
        execReq["executeRequest"]
    end

    subgraph pipelineMods ["requestPipeline/"]
        deps["dependencies"]
        headers["headers"]
        cache["cachePolicy"]
        status["statusHandling"]
        mwBridge["middlewareBridge"]
        fetchExec["fetchExecution"]
        pagOrch["pagination"]
    end

    handleReq --> cache
    handleReq --> deps
    execReq --> deps
    handleReq --> execReq
    handleSingle --> fetchExec
    handleSingle --> deps
    execReq --> fetchExec
    execReq --> status
    execReq --> cache
    execReq --> pagOrch
    execReq --> mwBridge
    fetchExec --> headers
    fetchExec --> mwBridge

    style coordinator fill:#e3f2fd,stroke:#1565c0
    style pipelineMods fill:#e8f5e9,stroke:#2e7d32
    style handleReq fill:#bbdefb,stroke:#1565c0
    style handleSingle fill:#bbdefb,stroke:#1565c0
    style execReq fill:#bbdefb,stroke:#1565c0
    style deps fill:#c8e6c9,stroke:#2e7d32
    style headers fill:#c8e6c9,stroke:#2e7d32
    style cache fill:#c8e6c9,stroke:#2e7d32
    style status fill:#c8e6c9,stroke:#2e7d32
    style mwBridge fill:#c8e6c9,stroke:#2e7d32
    style fetchExec fill:#c8e6c9,stroke:#2e7d32
    style pagOrch fill:#c8e6c9,stroke:#2e7d32

C4 Level 3 — Component: Resilience ​

How the resilience components nest around one call. Retry is outermost; deduplication sits inside each attempt; the circuit breaker and rate limiter run inside the fetch.

ComponentInterfacePurpose
RetryStrategyIRetryStrategyExponential backoff with jitter on retryable EsiErrors; one 401 token refresh per call
RequestDeduplicatorIDeduplicatorCoalesces identical in-flight GETs without a body, keyed by endpoint and caller identity
CircuitBreakerICircuitBreakerOpt-in per-key state machine (closed / open / half-open); key by resolved path or template
RateLimiterIRateLimiterPer-group buckets from the generated spec, learns from response headers, blocks a group on 420/429
ETagCacheManagerICacheSpec-TTL hits, If-None-Match conditionals, stale fallback on 5xx
mermaid
flowchart LR
    subgraph pipeline ["handleRequest"]
        spec["Spec-TTL cache hit?"]
        retry["RetryStrategy.execute"]
        dedup["Deduplicator"]
        execReq["executeRequest"]
    end

    subgraph fetch ["executeSingleFetch"]
        cb["CircuitBreaker"]
        rl["RateLimiter"]
        net["fetch"]
    end

    subgraph caching ["Caching"]
        etag["ETagCache"]
    end

    spec -- "miss" --> retry
    retry -- "each attempt" --> dedup
    dedup --> execReq
    execReq --> cb --> rl --> net
    execReq -- "write / 304 / stale" --> etag
    spec -. "read" .-> etag

    style pipeline fill:#e3f2fd,stroke:#1565c0
    style fetch fill:#fff3e0,stroke:#e65100
    style caching fill:#e8f5e9,stroke:#2e7d32

1. Layers ​

Dependency direction flows inward. Outer layers depend on inner layers, never the reverse.

mermaid
graph TB
    subgraph External["External"]
        ESI["ESI API<br/>(esi.evetech.net)"]
        Consumer["Consumer Application"]
    end

    subgraph PublicAPI["Construction Layer"]
        EsiClient["EsiClient"]
        EsiClientBuilder["EsiClientBuilder"]
        EsiApiFactory["EsiApiFactory"]
        Index["index.ts exports"]
    end

    subgraph DomainClients["Domain Client Layer"]
        Alliance["AllianceClient"]
        Character["CharacterClient"]
        Market["MarketClient"]
        Universe["UniverseClient"]
        More["... one per ESI domain"]
        Registry["ClientRegistry"]
    end

    subgraph EndpointLayer["Endpoint Definition Layer"]
        EndpointDef["EndpointDefinition<br/>(responseSchema + requestSchema)"]
        EndpointFiles["*Endpoints.ts (one per domain)"]
        CreateClient["createClient()<br/>→ InferEndpointResult&lt;D&gt;"]
    end

    subgraph SchemaLayer["Schema Validation Layer (Zod)"]
        Schemas["src/schemas/ (hand-written)"]
        SchemaValidation["Runtime Validation"]
    end

    subgraph CoreLayer["Core Request Orchestration"]
        ConfigureClient["configureApiClient()"]
        Pipeline["src/core/requestPipeline/"]
        Handler["ApiRequestHandler<br/>(coordinator)"]
        SpecTtlCache["Spec-Aware Cache (TTL bypass)"]
        BatchHandler["BatchRequestHandler"]
        Middleware["MiddlewareManager"]
        RateLimiter["RateLimiter"]
        CircuitBreaker["CircuitBreaker"]
        Cache["ETagCacheManager"]
        Pagination["Offset pagination"]
        CursorPagination["CursorPaginationHandler"]
        AsyncPaginator["AsyncPaginationIterator"]
        TokenRefresh["Token Refresh"]
    end

    subgraph GeneratedLayer["Generated (from ESI OpenAPI Spec)"]
        GenTypes["esi-spec.generated.ts"]
        GenTtls["esi-cache-ttls.generated.ts"]
        GenRateLimits["esi-rate-limit-groups.generated.ts"]
        GenScopes["esi-scopes.generated.ts"]
        GenOps["operations.generated.ts<br/>(called through ./client; §1a)"]
    end

    subgraph Interfaces["Interface Contracts"]
        ICache["ICache"]
        IRateLimiter["IRateLimiter"]
        ICB["ICircuitBreaker"]
        IRetry["IRetryStrategy"]
        IDedup["IDeduplicator"]
        ILogger["ILogger"]
    end

    subgraph Infrastructure["Infrastructure Layer"]
        ApiClient["ApiClient"]
        HeadersUtil["parseHeaders()"]
        Validation["validation"]
        ErrorUtil["EsiError, EsiValidationError"]
        Logger["pino Logger"]
        Constants["constants"]
    end

    Consumer --> Index
    Index --> EsiClient
    Index --> EsiClientBuilder
    Index --> EsiApiFactory

    EsiClient --> Alliance
    EsiClient --> Character
    EsiClient --> Market
    EsiClient --> Universe
    EsiClient --> More

    Alliance --> CreateClient
    Character --> CreateClient
    Market --> CreateClient
    Universe --> CreateClient

    CreateClient --> EndpointDef
    CreateClient --> EndpointFiles
    CreateClient --> SchemaValidation
    CreateClient --> Handler

    SchemaValidation --> Schemas

    Registry --> Alliance
    Registry --> Character
    Registry --> Market
    Registry --> Universe

    EsiClient --> ConfigureClient
    EsiClientBuilder --> ConfigureClient
    EsiApiFactory --> ConfigureClient
    ConfigureClient --> ApiClient

    Handler --> Pipeline
    Pipeline --> SpecTtlCache
    SpecTtlCache --> Cache
    SpecTtlCache --> GenTtls
    Pipeline --> Middleware
    Pipeline --> RateLimiter
    RateLimiter --> GenRateLimits
    Pipeline --> CircuitBreaker
    Pipeline --> Cache
    Pipeline --> Pagination
    Pipeline --> CursorPagination
    Pipeline --> TokenRefresh
    AsyncPaginator --> Handler
    EsiClient --> BatchHandler

    Cache -.->|implements| ICache
    RateLimiter -.->|implements| IRateLimiter
    CircuitBreaker -.->|implements| ICB
    Logger -.->|implements| ILogger

    Handler --> ApiClient
    Handler --> HeadersUtil
    Handler --> Validation
    Handler --> ErrorUtil
    Handler --> Constants

    ApiClient --> ESI

    style External fill:#f5f5f5,stroke:#999
    style PublicAPI fill:#e3f2fd,stroke:#1565c0
    style DomainClients fill:#e8f5e9,stroke:#2e7d32
    style EndpointLayer fill:#fff3e0,stroke:#e65100
    style SchemaLayer fill:#e0f7fa,stroke:#00838f
    style CoreLayer fill:#fce4ec,stroke:#c62828
    style Interfaces fill:#f3e5f5,stroke:#6a1b9a
    style Infrastructure fill:#eceff1,stroke:#37474f
    style GeneratedLayer fill:#e8eaf6,stroke:#283593

Design points:

  • One wiring function. configureApiClient() (src/core/configureApiClient.ts) is the single place configuration becomes middleware, for all three construction surfaces. The identity settings that vary by surface (base URL, client id, access token, datasource, language, compatibility date, token provider) are set by each surface before it calls configureApiClient(), and that is where the surfaces can drift (§9).
  • Typed returns from schemas. InferEndpointResult<D> applies z.infer<> to the endpoint's responseSchema. An endpoint without a schema returns unknown (DES-02).
  • Strategies, not imports. Cache, rate limiter, circuit breaker, deduplicator, retry strategy, transport and logger are all interfaces with a setter on ApiClient (ARCH-04).
  • Custom clients are the product. The hand-written domain clients and their construction surfaces are what ESI.ts is; the generated operations are the foundation underneath them, never their replacement (charter posture 1, ARCH-14, CLIENT-ARCHITECTURE.md).
  • Generated metadata, hand-written judgement. Cache TTLs, rate-limit groups and scopes are generated; clients, endpoint maps and schemas are written by hand and diffed against the spec (ARCH-01).
  • Measured today (npm run docs:metrics). 39 domain clients plus BaseEsiClient in src/clients/; 39 *Endpoints.ts maps wiring 235 endpoints; 36 domain schema modules plus common.ts and esiEnum.ts in src/schemas/; 235 generated operations. The endpoint and operation counts differ because the hand-written maps and the generated file are separate trees, reconciled by schema:drift and spec:coverage.

1a. Ports, adapters and the layer rule ​

Phase 2 of the 11.0 plan put seams under the pipeline so a spec-shaped client can be built on it without touching the custom-client surfaces. They are in the code today, covered by unit tests (tests/tdd/adapters/, tests/tdd/spec-generate/, tests/tdd/layers/), and reach consumers through src/client/, the ./client entry (PR 11, MULTI-CHARACTER.md): createEsi(options) builds one ApiClient through configureApiClient and hands out esi.public, a PublicScopeTree whose transport refuses a scoped operation with NO_AUTH_TOKEN, and esi.as(identity), a ScopeTree over a second ApiClient that holds the identity's token and shares the runtime's cache, rate limiter, circuit breaker, deduplicator, retry settings and transport. The specification is tests/bdd/features/core/0056-shared-runtime.feature. EsiClient, CustomEsiClient and EsiApiFactory do not use the seams.

The ports ​

src/core/ports/ holds seven type-only declarations, all exported from ./client. A port imports nothing, packages included, so an adapter or a test double can implement one without pulling in the pipeline.

PortShapeWhat implements it today
CacheStoreget, set(key, etag, data, headers, ttlMs?), delete, clear, shutdown over CachedResponseEsiClientConfig.cache and EsiOptions.cache take one; CacheStoreCache adapts it to the wider ICache (getETag, has, deleteByPath, getStats) that ETagCacheManager implements
Clocknow(): number, sleep(ms): Promise<void>systemClock in src/core/clock.ts. The clock option (EsiClientConfig, EsiOptions) reaches ApiClient.setClock, the cache, rate limiter and circuit breaker
HttpTransport(input, init?) => Promise<Response>Structurally the same as FetchLike, so ApiClient.setFetch() accepts one
Loggerfatal, error, warn, info, debug, trace with optional fieldsStructurally the same as ILogger, so the pino default and any ILogger fit
OperationTransportrequest<T>(meta, req), paginate<T>(meta, req): AsyncIterable<T>PipelineTransport in src/adapters/
TokenProvider() => Promise<string>The onTokenRefresh option and EsiTokenManager.tokenProviderFor(characterId) have this shape
IdentitycharacterId?, accessToken(): Promise<string>, refreshAccessToken?(): Promise<string>EsiTokenManager.identity(characterId), identityFromToken and identityFromProvider in src/client/

OperationMeta describes one spec operation: operationId, method, path template, scopes (empty for a public route), pagination ('none' | 'page' | 'cursor'), deprecated, and the header parameters the transport supplies. OperationRequest carries path, query and an optional body.

Generated operations and the scope tree ​

src/generated/operations.generated.ts is written by scripts/spec/spec-generate.ts from the vendored snapshot at compatibility date 2026-08-18 (COMPATIBILITY_DATE in src/core/constants.ts). For each of the spec's 233 operations, and the two scripts/spec/operation-overlay.json adds (235 in all), it holds a *Meta constant and a function that takes an OperationTransport and typed parameters. scripts/spec/spec-scope-tree.ts appends three more exports:

  • ScopeTree: every operation arranged by path prefix, so GET /characters/{character_id}/wallet/journal is tree.character(id).wallet.journal.get().
  • PublicScopeTree: the same arrangement holding only the operations whose scopes are empty. An authenticated operation is absent from the type, so calling one does not compile.
  • createScopeTree(transport): builds a ScopeTree over one transport.

npm run spec:generate:check fails when the committed file differs from what the generator writes, and npm run spec:coverage fails when an operation in the snapshot has no generated function or the two disagree. Both run in the lint-and-build job of ci.yml.

PipelineTransport ​

src/adapters/PipelineTransport.ts implements OperationTransport over an ApiClient. request fills the path template (each value validated and URL-encoded), joins array query values with commas as the hand-written clients do, appends datasource when one is set, sets requiresAuth from meta.scopes.length > 0, and calls handleRequest with the template as the TTL and rate-limit key. paginate walks X-Pages through fetchPages and yields one item at a time. It holds no state, so generated operations share the client's rate limiter, circuit breaker, deduplicator, retry strategy, ETag cache and identity headers with the hand-written clients.

Two differences from a domain method follow from that route:

  • Generated Zod validation. createClient validates against the hand-written schemas in src/schemas/; PipelineTransport validates against src/adapters/responseSchemas.generated.ts, one schema per operation generated from the same document as the types, when createEsi is given validateResponse: true. Unset or false, the body is returned unvalidated, as before; the default for createEsi will become validating in a future major. The runtime passes validateResponse: true to PipelineTransport only for an explicit true, because the shared ApiClient default (true) is what keeps the hand-written clients validating. A body that fails throws EsiValidationError and its cache entry is evicted, as in createClient. Where ESI's wire responses break the document, scripts/spec/response-schema-overrides.json widens the generated schema (the operation's TypeScript type keeps the document's shape until the next major, since widening it is a major change), and tests/tdd/adapters/responseSchemas.recorded.test.ts runs every recorded response through its schema.
  • Separate cache keys for 54 routes. Generated paths follow the spec and have no trailing slash. The 54 hand-written routes whose paths end in / therefore cache under a different key from their generated operation.
ts
import { createEsi } from '@lgriffin/esi.ts/client';

const esi = createEsi({
  userAgent: 'my-app/1.0 (you@example.com)',
  validateResponse: true,
});
const status = await esi.public.status.get(); // typed and validated from the spec

The layer rule ​

npm run lint:layers runs the local ESLint rule layers/inward-imports from config/eslint/layers.rules.cjs with --no-inline-config, so an eslint-disable comment cannot get round it. It runs in ci-fast.yml on every push, in ci.yml lint-and-build, and in check:local. The rule resolves each specifier against the importing file and reads static imports, re-exports, import x = require(), import('...') types and expressions, and require().

ImporterMay not import
src/core/ports/Anything outside src/core/ports/, packages included
src/generated/Anything except src/core/ports/
src/core/clients/, EsiClient, EsiClientBuilder, index, generated/, auth/, sde/, testing/, client/, adapters/
src/client/, src/adapters/The custom-client trees: clients/, EsiClient, EsiClientBuilder, index
src/sde/Anything in src/ outside src/sde/ except the ports; any package but node:*, zod, js-yaml, adm-zip
Anything else in src/src/sde/, or the package's own ./sde sub-paths (ARCH-10, #462)

src/client/ is the ./client entry (runtime.ts, identity.ts); it imports src/core, src/adapters and src/generated and none of the custom clients. No file breaks the core rule: BASELINE in config/eslint/layers.rules.cjs is empty since Phase 3 moved ClientRegistry to src/clients/ and EsiClientConfig to src/core/, and tests/tdd/layers/layers-lint.test.ts fails if an entry is added. The authoring view of the same rule is DESIGN-RULES.md §7.

mermaid
flowchart BT
    ports["src/core/ports<br/>(imports nothing)"]
    generated["src/generated"]
    core["src/core (pipeline)"]
    adapters["src/adapters"]
    client["src/client (./client entry)"]
    legacy["custom clients: clients/, EsiClient, EsiClientBuilder, index"]
    auth["src/auth"]
    sde["src/sde (side module)"]

    generated --> ports
    core --> ports
    adapters --> core
    adapters --> ports
    client --> adapters
    client --> core
    client --> generated
    legacy --> core
    legacy --> auth
    auth --> core
    auth --> ports
    auth -. "EsiTokenManager.createClient" .-> legacy
    sde --> ports

    style ports fill:#f3e5f5,stroke:#6a1b9a
    style sde fill:#eceff1,stroke:#37474f

Solid arrows are the directions the rule permits for the ruled trees (ports, generated, core, adapters, SDE). Auth and the custom clients have no rule between them: index.ts re-exports src/auth, and EsiTokenManager.createClient constructs an EsiClient (the dashed arrow), so the two import each other at the directory level. Phase 7 deprecates EsiTokenManager.createClient in favour of as(); removing it, and the dashed arrow with it, needs a future major release that is not scheduled. The custom clients it builds stay (CLIENT-ARCHITECTURE.md).

What 11.0.0 still changes ​

Each item is one pull request in ROADMAP.md. None removes a custom-client surface.

WorkWhat changes in this architecture
Phase 3 · layer baseline to emptyDone: ClientRegistry.ts is in src/clients/, EsiClientConfig in src/core/; BASELINE is {}.
Phase 4 · logging and import-time behaviourURL sanitising moves to the logger boundary, the remaining call sites that bypass the per-client logger migrate to it (ARCH-09), no pino instance is built at import, and package.json declares sideEffects: false (ARCH-06).
Phase 7 · Node 22engines.node becomes >=22.12.0 in the one feat!: commit of the release (REL-05).

2. Request Lifecycle ​

Every domain method takes the same route. Order matters: a stage cannot see the effect of a later one.

  1. Domain method → createClient closure (src/core/endpoints/createClient.ts). Logs a deprecation warning if the definition carries deprecated. buildEndpointPath assembles the path, query and body, and appends datasource when one is set. The request body is validated against requestSchema only when validateRequest is on; failure throws EsiValidationError with direction: 'request'.
  2. Spec-aware cache check (trySpecAwareCacheHit). For a GET whose template has a generated TTL, a cached entry younger than that TTL is returned with no network call.
  3. Retry (RetryStrategy.execute). Wraps everything below. Every attempt after the first checks the spec-TTL cache again before sending, because a concurrent call may have stored a fresh copy during the backoff. See §5.
  4. Deduplication. Inside each attempt, an identical in-flight GET without a body joins the existing promise. The key is buildDedupeKey(endpoint): the endpoint, prefixed by the same identity hash the cache uses when the endpoint requires auth.
  5. executeSingleFetch. Build headers (below) → request interceptors → circuit breaker check → rate limiter check → fetch with timeout → parse headers → rate limiter learns from the response → circuit breaker records the outcome.
  6. Status handling (executeRequest, then statusHandling.ts).
    • 201 or 204 on a non-GET first runs invalidateAfterWrite (see §4).
    • 201 parses the body if there is one and returns it.
    • emptyWhenNoContent. On the two routes that opt in (getPublicContractBids and getPublicContractItems, where ESI answers an expired contract with a 200 and Content-Length: 0), such a 200 resolves with no body, and createClient turns that, or a 204, into []. Elsewhere an empty 200 body is a JSON_PARSE_ERROR.
    • 304 with the entry gone. When the request was sent as a revalidation but its cache entry was evicted while it was in flight (a write to its path, or expiry), executeRequest runs again; the repeat finds no entry, sends no If-None-Match and gets the current representation.
    • 304 otherwise serves the cached body and stores it again, restarting its TTL, or throws EsiError(304) if there is none. 204 returns undefined.
    • 5xx with a cached copy serves it stale. Any other non-2xx throws EsiError; the message carries ESI's own { "error": "..." } reason when the body has one (readEsiErrorReason, trimmed to 200 characters), and 401 and 403 add remediation text.
    • 2xx body. An endpoint with textResponse (today only meta.getOpenApiYaml) reads the body as text; every other endpoint parses JSON.
  7. Cache write and pagination. A successful GET with an ETag is cached, unless a write invalidated its path while it was in flight (the write generation, §4). A multi-page response is not cached here: handleOffsetPagination caches it once, after every page is merged. A non-GET that reaches this step invalidates the path. Cursor pagination reads x-cursor-before / x-cursor-after; offset pagination follows x-pages and merges the pages.
  8. Response interceptors → Zod validation → envelope. Interceptors see the assembled response. When validateResponse is on (the default) the body is replaced by safeParse().data, or the cached copy is evicted (evictRejectedResponse) and EsiValidationError is thrown. withMetadata() and withSafeMode() wrap the result last.

Request headers (buildRequestHeaders in requestPipeline/headers.ts):

HeaderWhenValue
AcceptAlwaysapplication/json, or the endpoint's textResponse.accept (meta.getOpenApiYaml asks for YAML)
Accept-EncodingAlwaysgzip, deflate, br
User-AgentAlways<userAgent> @lgriffin/esi.ts/<version> when a userAgent is configured, else the library identifier alone
X-Compatibility-DateAlwaysThe client's compatibilityDate, else COMPATIBILITY_DATE (2026-08-18) from src/core/constants.ts
X-User-AgentWhen there is a valueThe configured userAgent, else the clientId if it is a legal header value
X-TenantWhen tenant is setThe tenant
Accept-LanguageWhen language is setThe language
AuthorizationOnly when requiresAuthBearer <token>; with no token configured the call throws NO_AUTH_TOKEN before any network I/O
If-None-MatchGET, cache on, ETag storedThe stored ETag for this identity's cache key
Content-TypeWhen there is a bodyapplication/json

Happy Path ​

mermaid
sequenceDiagram
    participant App as Consumer
    participant DC as DomainClient
    participant CC as createClient
    participant HR as handleRequest
    participant FE as fetchExecution
    participant ESI as ESI API

    App->>DC: getMarketPrices()
    DC->>CC: build path (+ optional request validation)
    CC->>HR: handleRequest()
    HR->>HR: trySpecAwareCacheHit()
    Note over HR: miss → retry → dedup → executeRequest
    HR->>FE: executeSingleFetch()
    Note over FE: headers → interceptors → CB → rate limit → fetch
    FE->>ESI: HTTP request
    ESI-->>FE: 200 + JSON
    FE-->>HR: response + parsed headers
    Note over HR: cache write, pagination, response interceptors
    HR-->>CC: { headers, body }
    Note over CC: Zod validation
    CC-->>App: typed data

Error and Edge-Case Handling ​

mermaid
sequenceDiagram
    participant HR as executeRequest
    participant SH as statusHandling
    participant Cache as ETagCache
    participant PO as pagination
    participant RS as RetryStrategy

    alt 201 / 204 on a write
        HR->>Cache: invalidateAfterWrite(path)
        HR-->>HR: body (201) or undefined (204)
    else 304, entry evicted in flight
        HR->>HR: executeRequest() again, no If-None-Match
    else 304 Not Modified
        HR->>SH: handleEarlyStatus()
        SH->>Cache: get(key)
        Cache-->>HR: cached data (or EsiError 304)
    else 401 + token provider
        HR-->>RS: throw EsiError 401
        RS->>RS: refreshToken() once, re-run attempt
    else 5xx + cached data
        HR->>SH: handleErrorResponse()
        SH->>Cache: stale fallback
        Cache-->>HR: stale data
    else 4xx / 5xx (no cache)
        HR->>SH: handleErrorResponse()
        SH-->>RS: throw EsiError (retried if retryable)
    else Cursor headers present
        HR->>PO: handleCursorPagination()
        PO-->>HR: data + cursors
    else Multi-page (x-pages > 1)
        HR->>PO: handleOffsetPagination()
        PO-->>HR: merged pages
    end

3. Dependency Injection ​

Every resilience feature is behind an interface and scoped to one ApiClient instance; there are no shared singletons in the pipeline. A null dependency disables that feature for that client.

configureApiClient() builds the defaults from EsiClientConfig. At request time requestPipeline/dependencies.ts resolves each one:

DependencySetter on ApiClientDefault from configureApiClientResolution when absent
ICachesetCache()ETagCacheManager unless enableETagCache: falseresolveCache() → null: no ETag, spec-TTL or stale caching
IRateLimitersetRateLimiter()RateLimiter, alwaysresolveRateLimiter() throws CONFIGURATION_ERROR
ICircuitBreakersetCircuitBreaker()CircuitBreaker only if enableCircuitBreakerresolveCircuitBreaker() → null: no circuit breaking
IRetryStrategysetRetryStrategy()none; retryConfig of 3 retries, 1 s, 30 sresolveRetryStrategy() → new RetryStrategy(retryConfig)
IDeduplicatorsetDeduplicator()RequestDeduplicator unless disabledread directly; null means no coalescing
FetchLikesetFetch()noneglobalThis.fetch
ILoggersetLogger()only when logger or logLevel is configuredglobal logger, then the pino default (see LOGGING.md)

Per-client settings are plain values with a setter each. configureApiClient or the construction surface sets them from EsiClientConfig:

SettingSetterSet byEffect
tenantsetTenant()configureApiClientX-Tenant; refused when not a legal header value
userAgentsetUserAgent()configureApiClientPrefixes User-Agent, sets X-User-Agent; validated in the setter
compatibilityDatesetCompatibilityDate()All three surfacesX-Compatibility-Date
languagesetLanguage()each surfaceAccept-Language
datasourcesetDatasource()each surfacedatasource query parameter
onTokenRefreshsetTokenProvider()each surface401 refresh (§5)
accessTokensetAccessToken()the ApiClient constructorAuthorization on requiresAuth endpoints
validateResponse / validateRequestsetValidateResponse() / setValidateRequest()configureApiClientZod validation (§10)
timeoutsetTimeout()configureApiClientPer-request AbortController timeout, default 30 s
incompletePagessetIncompletePages()configureApiClientTruncate (default) or throw on an incomplete page walk (PAGINATION.md)

Time is injectable on every surface. src/core/clock.ts exports systemClock, the Clock port's real implementation, and npm run lint:determinism blocks new direct reads of Date.now(), timers and Math.random() in src/. A clock in the config (new EsiClient({ clock }), the builder's withConfig, EsiApiFactory.createClient, or createEsi({ clock })) reaches configureApiClient, which puts the Clock on the ApiClient and into the ETagCacheManager, RateLimiter and CircuitBreaker it builds, so cache age, retry back-off, rate-limit waits and the circuit reset timeout follow it; without one they read the wall clock through systemClock and wait on real timers. The cleanup intervals and the request timeout still run on real time (their sites remain in scripts/quality/determinism-baseline.json). EsiTokenManager takes a now function for tests.

The ports are not the interfaces above. The ApiClient setters take the I* interfaces. The Phase 2 ports in §1a are the seams ./client exports; the existing classes match HttpTransport, Logger and TokenProvider structurally but none declares implements for a port.

mermaid
flowchart LR
    subgraph ConfigFactory ["configureApiClient()"]
        Wire["Config → Middleware"]
    end

    subgraph Client ["ApiClient instance"]
        cache["ICache"]
        rl["IRateLimiter"]
        cb["ICircuitBreaker"]
        dedup["IDeduplicator"]
        retry["IRetryStrategy"]
        fetch["FetchLike"]
        logger["ILogger"]
    end

    subgraph Resolution ["dependencies.ts"]
        rc["resolveCache"]
        rr["resolveRateLimiter"]
        rcb["resolveCircuitBreaker"]
        rrt["resolveRetryStrategy"]
    end

    Wire --> Client
    rc --> cache
    rr --> rl
    rcb --> cb
    rrt --> retry

    style ConfigFactory fill:#fff3e0,stroke:#e65100
    style Client fill:#e3f2fd,stroke:#1565c0
    style Resolution fill:#e8f5e9,stroke:#2e7d32

Middleware inventory ​

ConcernInterfaceDefaultKey defaults
Rate limiterIRateLimiteron, mandatoryBuckets from generated groups; 420/429 → 60 s block; 50 ms minimum spacing
Circuit breakerICircuitBreakeroff, opt-in5 failures, 30 s reset, 1 half-open probe, key by resolved path
DeduplicatorIDeduplicatoronGET without body only
RetryIRetryStrategyon3 retries, 1 s base, 30 s cap, GET only
ETag cacheICacheon1000 entries, 5 min default TTL, 60 s sweep
InterceptorsRequestInterceptor / ResponseInterceptornoneSequential, unsubscribe closure returned
TransportFetchLikeglobal fetch30 s timeout
LoggerILoggerpino, level warnPer-client, falls back to global, then default

4. Caching ​

Caching is on by default and has three tiers, checked in order.

Request (GET)
  │
  ▼
Tier 1  Spec TTL        entry younger than the generated TTL → return it, no HTTP call
  │ miss or expired
  ▼
Tier 2  ETag            send If-None-Match → 304 returns the cached body
  │ changed or no entry
  ▼
Tier 3  Full request    200 → cache it (if ESI sent an ETag)

Spec TTL. esi-cache-ttls.generated.ts maps METHOD:template to the x-cache-age value from the OpenAPI spec. Only GETs whose template has an entry are eligible. Within that window the stored body is returned with cacheHitType: 'spec-ttl'.

Lifetime of a stored entry. The freshness TTL is the generated spec TTL, else Cache-Control: max-age. An entry is kept for its freshness TTL plus one hour (STALE_RETENTION_MS in cachePolicy.ts), so after the spec-TTL window closes the entry still supplies If-None-Match and a stale body. An entry whose response gave no freshness TTL is kept for the cache's defaultTtl (5 minutes). Only responses that carry an ETag are stored. A 304 stores the entry again, which restarts both windows.

Stale on error. When ESI returns a 5xx and a cached entry exists, that entry is returned with stale: true and cacheHitType: 'stale-on-error' instead of throwing. Because nothing is thrown, the retry strategy does not retry that call. For a spec-TTL endpoint this applies from the end of the spec TTL until the retention hour runs out.

Write invalidation. Every successful non-GET, including the body-less 201 and 204 replies, runs invalidateAfterWrite (cachePolicy.ts) with the request path, query stripped. It does three things, in this order:

  1. Detaches in-flight deduplicated reads under that path (IDeduplicator.detachByPath), so a read that starts after the write is not handed the response to one that started before it.
  2. Deletes every cache entry whose key contains the path (ICache.deleteByPath).
  3. Bumps the cache's write generation and records the path. A GET takes the generation before it is sent (currentWriteGeneration); cacheResponse declines to store its body if a write to the same path happened while it was in flight. The last 64 writes are kept; a read that outlived more is treated as overtaken.

Multi-page responses are cached once, by handleOffsetPagination, after every page is merged. Page 1 is not cached on its own, so a retried call cannot revalidate against page 1 and resolve with it after a 304.

Validation failure evicts. executeRequest caches a GET body before createClient validates it. When the body fails the schema, evictRejectedResponse deletes the entry, so the rejected body is not served again from the spec TTL or after a 304.

Keys and isolation. Public endpoints are keyed by URL and shared across callers of the same client. Authenticated endpoints prefix the key with the caller's identity (src/core/cache/cacheKey.ts, src/core/util/callerIdentity.ts): character:<id> read from the sub claim of an EVE SSO token once ESI has answered a request under that token (a 2xx or 304 marks it accepted; until then the token is keyed by its hash and sends the character's ETag only as If-None-Match, so a forged claim earns a 401 rather than an entry), or the first 16 hex characters of a SHA-256 hash of the Authorization header for a token that names no character. A token refresh keeps the character's entries, two characters never share a cached body, and the in-flight deduplicator draws the same line. See SECURITY.md.

Eviction. When maxEntries is reached, storing a new key evicts the oldest entry; replacing a stored key evicts nothing. Retention past the freshness TTL does not raise this bound. A timer removes expired entries every cleanupInterval; shutdown() stops it.

typescript
const client = new EsiClient({
  enableETagCache: true, // default
  etagCacheConfig: {
    maxEntries: 1000, // default
    defaultTtl: 300_000, // ms, entry lifetime when neither spec nor Cache-Control gives a TTL
    cleanupInterval: 60_000, // ms
  },
});

const stats = client.getCacheStats(); // { totalEntries, maxEntries, hits, misses, hitRate, oldestEntry, newestEntry } or null
client.updateCacheConfig({ maxEntries: 2000 });
client.clearCache();

// See where a response came from
const result = await client.alliance.withMetadata().getAllianceById(99000001);
result.meta.fromCache; // boolean
result.meta.cacheHitType; // 'spec-ttl' | 'etag-304' | 'stale-on-error' | undefined

Set enableETagCache: false to disable all three tiers.


5. Retry and Deduplication ​

Retry (src/core/RetryStrategy.ts) wraps each call from handleRequest and each page from handleSinglePageRequest.

RuleBehaviour
What is retriedAn EsiError whose retryable is true: status 0 (network or timeout), 420, 429, 502, 503, 504
Which methodsGET only, unless retryMutations: true
DelaybaseDelayMs × 2^attempt × jitter(0.75–1.25), capped at maxDelayMs
DefaultsconfigureApiClient sets maxRetries: 3, baseDelayMs: 1000, maxDelayMs: 30000; retryAttempts overrides only the count
Token refreshOne refresh per call on a 401 when the endpoint requires auth and a token provider is set; the attempt is then re-run
Cache re-checkFrom the second attempt on, handleRequest checks the spec-TTL cache before sending; a copy stored during the backoff is served
Refresh failureRethrown as-is if it is an EsiError, otherwise an EsiTokenRefreshError with the original on cause
Circuit openCircuitOpenError is rethrown immediately and never retried

Concurrent refreshes on one ApiClient share one in-flight provider call. Supply a custom IRetryStrategy through retryStrategy in the config or ApiClient.setRetryStrategy().

Deduplication (src/core/RequestDeduplicator.ts) keys by buildDedupeKey: the resolved endpoint, prefixed with the caller's identity hash when the endpoint requires auth, so two concurrent GETs under different tokens are never answered from one response. It applies only to GETs without a body. Callers that arrive while a request is in flight receive the same promise, including its rejection. Because it sits inside the retry loop, each retry attempt re-enters the deduplicator. A successful write detaches in-flight reads under its path (§4). Disable it with enableRequestDeduplication: false.


6. Rate Limiting ​

ESI assigns most endpoints to a named rate-limit group with its own token bucket. The groups are extracted from the OpenAPI spec into esi-rate-limit-groups.generated.ts, so a burst against market-order does not throttle char-notification. Rate limiting is always on and needs no configuration.

What the limiter does before each fetch (RateLimiter.checkRateLimit):

  1. Blocked group. If the group is blocked, sleep until the block ends and check again, up to ten times. If it is still blocked, throw a retryable EsiError(429) rather than send the request.
  2. Legacy error limit. If x-esi-error-limit-remain is 10 or fewer, wait up to 5 s; if it is exhausted, wait for x-esi-error-limit-reset.
  3. Bucket pressure. If the group's bucket is empty, wait 1 s. If remaining tokens are at or below decelerationThreshold (20%) of the limit, add a delay that grows to 1 s as the bucket drains.
  4. Minimum spacing. Serialise requests so they are at least minDelayMs (50 ms) apart.

What it learns after each fetch (updateFromResponse): x-ratelimit-remaining, -limit and -used overwrite the bucket, and x-ratelimit-group selects the bucket when ESI names one. Retry-After blocks the group for that many seconds, or until that date when it is an HTTP date (an unreadable value is ignored); a 420 or 429 blocks it for 60 s if nothing longer is set. The limiter does not decrement tokens locally; ESI's headers are the source of truth. ESI's own charging table (2xx = 2, 3xx = 1, 4xx = 5, 5xx = 0) is exposed as RateLimiter.getTokenCost(status) for callers that want to budget.

Endpoints without a group share one fallback bucket that is only ever blocked by a 420/429.

typescript
import { createHash } from 'node:crypto';

const client = new EsiClient({
  rateLimiterConfig: {
    minDelayMs: 50, // default
    decelerationThreshold: 0.2, // default
    // Multi-character apps: one set of group buckets and one error limit per key
    // The key is held in memory as a bucket name, so hash the token, never return it
    userKeyExtractor: (headers) => {
      const auth = headers['Authorization'];
      return auth
        ? createHash('sha256').update(auth).digest('hex').slice(0, 16)
        : 'anon';
    },
    // Tighter local budget for one endpoint (key uses snake_case params, no trailing slash)
    endpointOverrides: {
      'GET:markets/{region_id}/orders': {
        maxTokens: 100,
        windowSizeMs: 60_000,
      },
    },
  },
});

userKeyExtractor receives the outgoing request headers, after request interceptors have run. Its return value is stored unhashed as a map key, so it must not be the raw Authorization header. Idle user bucket sets are dropped after 15 minutes.

Monitoring. Per-response rate-limit state is available on withMetadata() results as meta.rateLimit. The IRateLimiter instance exposes getStatus() (worst bucket across all groups), getGroupStatus(group), getAllGroupStatuses() and isBlocked(group?); the last three read the shared buckets, not per-user ones. EsiClient does not expose the limiter directly; hold a reference by constructing an ApiClient yourself or by passing your own IRateLimiter to ApiClient.setRateLimiter().

mermaid
flowchart TB
    start["checkRateLimit()"] --> blocked{"Group blocked?"}
    blocked -->|Yes| wait["Sleep until unblocked<br/>(10 checks, then EsiError 429)"]
    blocked -->|No| legacy{"Error limit ≤ 10?"}
    wait --> legacy
    legacy -->|Yes| slow["Wait ≤ 5 s or until reset"]
    legacy -->|No| bucket{"Bucket empty or ≤ 20%?"}
    slow --> bucket
    bucket -->|Yes| decel["Wait up to 1 s"]
    bucket -->|No| spacing["Minimum spacing"]
    decel --> spacing
    spacing --> pass["Send request"]

    style start fill:#e8f5e9,stroke:#2e7d32

7. Circuit Breaker ​

The circuit breaker stops sending requests to a key that keeps failing. It is off by default; turn it on with enableCircuitBreaker: true. circuitBreakerConfig on its own does nothing.

mermaid
stateDiagram-v2
    [*] --> Closed

    Closed --> Closed: success or 4xx (resets count)
    Closed --> Open: failures >= failureThreshold

    Open --> Open: resetTimeoutMs not elapsed
    Open --> Open: success of a call admitted before it opened
    Open --> HalfOpen: next request after resetTimeoutMs

    HalfOpen --> Closed: probe succeeds
    HalfOpen --> Open: probe fails

    note right of Closed
        Failures: status 0 (network, timeout),
        420, 429, 5xx. Any other response
        is a success and resets the count.
    end note

    note right of Open
        checkCircuit throws CircuitOpenError
        with retryAfterMs. No HTTP call.
    end note

    note left of HalfOpen
        Probe requests are admitted up to
        halfOpenMaxAttempts, counting the call
        that moved the circuit to half-open.
        Extra requests throw CircuitOpenError.
    end note

Where it runs. In executeSingleFetch, after request interceptors and before the rate limiter. A try/finally with a cbRecorded flag records a failure if anything between the check and the response throws, including a rate-limiter abort, so a half-open probe slot is never leaked. The retry strategy rethrows CircuitOpenError without retrying.

Configuration (CircuitBreakerConfig):

OptionDefaultDescription
failureThreshold5Consecutive failures before opening
resetTimeoutMs30000Time after the last failure before a request may probe
halfOpenMaxAttempts1Probe admissions counted in half-open
keyStrategy'resolved'How requests map to circuits; see below
staleThresholdMs3600000Age after which a closed, zero-failure circuit is removed by cleanup()
cleanupIntervalMsdisabledRuns cleanup() on an unref'd timer when set above 0; staleThresholdMs is a sensible value

Keying.

StrategyKeyEffect
'resolved'Resolved path without query, e.g. characters/12345/One failing character does not open the circuit for others
'template'Endpoint definition path, e.g. characters/{characterId}/One circuit for the whole endpoint; detects a systemic ESI failure

Handling and diagnostics.

typescript
import {
  EsiClient,
  isCircuitOpen,
  createConsoleLogger,
} from '@lgriffin/esi.ts';

const log = createConsoleLogger('info');

const client = new EsiClient({
  enableCircuitBreaker: true,
  circuitBreakerConfig: { keyStrategy: 'template' },
});

try {
  await client.characters.getCharacterPublicInfo(12345);
} catch (err) {
  if (isCircuitOpen(err)) {
    log.warn(`${err.endpoint} open, retry in ${err.retryAfterMs} ms`);
  }
}

const stats = client.getCircuitBreakerStats();
// { totalCircuits, openCircuits, circuits: { [key]: { state, failures } } } or null when disabled

client.resetCircuitBreaker('characters/{characterId}/'); // one key, in the format of the active strategy
client.resetCircuitBreaker(); // all circuits

client.shutdown(); // stops the cleanup timer and clears circuits

CircuitOpenError extends EsiError with statusCode 0 and .retryable false, so isEsiError(err) is true for it. See ERRORS.md.


8. Interceptors ​

Interceptors hook into the pipeline for logging, metrics, tracing headers or response transformation. MiddlewareManager (src/core/middleware/Middleware.ts) holds two ordered lists; middlewareBridge.ts applies them.

typescript
interface RequestContext {
  url: string; // full URL
  endpoint: string; // resolved path
  method: string;
  headers: Record<string, string>; // a copy; return it to apply changes
  body?: unknown;
}

interface ResponseContext {
  url: string;
  endpoint: string;
  method: string;
  status: number;
  headers: Record<string, string>;
  body: unknown;
  durationMs: number;
  fromCache: boolean;
}

type RequestInterceptor = (
  ctx: RequestContext,
) => RequestContext | Promise<RequestContext>;
type ResponseInterceptor = (
  ctx: ResponseContext,
) => ResponseContext | Promise<ResponseContext>;

Registration and removal. Pass arrays as requestInterceptors / responseInterceptors in the config, or add them at runtime. Each add* returns an unsubscribe function that removes that interceptor.

typescript
const client = new EsiClient({
  requestInterceptors: [
    (ctx) => ({
      ...ctx,
      headers: { ...ctx.headers, 'X-Trace-Id': crypto.randomUUID() },
    }),
  ],
});

const unsubscribe = client.addResponseInterceptor((ctx) => {
  metrics.histogram('esi.response_ms', ctx.durationMs, {
    status: ctx.status,
    cached: ctx.fromCache,
  });
  return ctx;
});
unsubscribe();

Ordering and scope.

RuleBehaviour
OrderRegistration order. Each interceptor is awaited and receives the previous one's return value
Request interceptors runOn every HTTP attempt: each retry and each page. After header construction, before the circuit breaker and rate limiter
Request changes appliedurl, headers and body. method and endpoint are informational
Response interceptors runOnce per handleRequest call on the assembled result, including spec-TTL and stale cache hits, after offset pages are merged
Response changes appliedheaders and body. Zod validation runs after, on the modified body
Not runResponse interceptors do not run when the call throws, or on the per-page path used by stream* and fetchAll*
Cost when unusedBoth bridges return immediately when no interceptor is registered

An interceptor that throws fails the request with that error. Because request interceptors see the Authorization header, treat them as trusted code.


9. Client Creation Patterns ​

Three construction surfaces, all wired by configureApiClient(), so middleware is identical whichever one is used. constructionParity.test.ts checks that.

PatternUse caseWhat you get
EsiClientMost consumersEvery domain client as a lazy getter (client.market, client.alliance, …)
EsiClientBuilderSelectiveA CustomEsiClient with only the clients you add, and fluent configuration
EsiApiFactorySingle-domain scriptsOne domain client on its own ApiClient; the named methods are deprecated

ClientRegistry maps each of the 39 ApiClientType names to its class. ARCH-08 requires every surface to expose the same set. CustomEsiClient has a getter for every registered client, checked at compile time by tests/tdd/core/customClientGetters.test.ts. EsiApiFactory reaches all 39 through createClient(type) and has named methods for 9 (createAllianceClient, createCharacterClient, createCorporationClient, createMarketClient, createUniverseClient, createFleetClient, createAssetsClient, createWalletClient, createMailClient), deprecated since 11.0.0 in favour of createClient(type) and the builder; nothing checks its client set. ARCH-14 keeps all three surfaces and every domain client undeprecated.

All three surfaces call setCompatibilityDate() from the config before configureApiClient(). constructionParity.test.ts checks that a compatibilityDate reaches the ApiClient from each of them.

ApiClientBuilder (src/core/ApiClientBuilder.ts, exported from the root) is a fourth, lower-level way in. It builds a bare ApiClient with a rate limiter (the default RateLimiter unless one is given) and whatever cache, circuit breaker, timeout and fetch it is handed. It does not call configureApiClient(), so there is no deduplicator, no default cache, and no retry configuration: resolveRetryStrategy then builds a RetryStrategy with its own defaults, which retry nothing. Use it to hand-assemble a client for a test or an unusual host, not as a construction surface.

EsiTokenManager.createClient(characterId) returns an EsiClient bound to one character's token and refresh provider. 11.0.0 deprecates it and EsiApiFactory's named methods (JSDoc @deprecated, no runtime warning) in favour of createClient(type), the builder and, for many characters, esi.as(tokens.identity(characterId)) (MULTI-CHARACTER.md). Removing them needs a future major release; none is scheduled. 12.1.0 also deprecated the rest of the construction tree, and 12.2.0 lifted that (CLIENT-ARCHITECTURE.md).

Timers are owned by the client: call shutdown() to stop the cache sweep and circuit-breaker cleanup and clear the deduplicator.

mermaid
flowchart TB
    subgraph P1 ["EsiClient (full)"]
        p1["new EsiClient()"]
    end
    subgraph P2 ["Builder (selective)"]
        p2["EsiClientBuilder\n.addClients().build()"]
    end
    subgraph P3 ["Factory (single)"]
        p3["EsiApiFactory\n.createMarketClient()"]
    end

    subgraph CW ["configureApiClient()"]
        cw["Unified wiring"]
    end

    subgraph Infra ["Per-ApiClient Infrastructure"]
        ac["ApiClient"]
        cache["ETagCache"]
        rl["RateLimiter"]
        cb["CircuitBreaker"]
        retry["RetryStrategy"]
        dedup["Deduplicator"]
    end

    p1 --> cw
    p2 --> cw
    p3 --> cw
    cw --> ac
    ac --> cache
    ac --> rl
    ac --> cb
    ac --> retry
    ac --> dedup

    style P1 fill:#e3f2fd,stroke:#1565c0
    style P2 fill:#e8f5e9,stroke:#2e7d32
    style P3 fill:#fff3e0,stroke:#e65100
    style CW fill:#fce4ec,stroke:#c62828
    style Infra fill:#eceff1,stroke:#37474f

10. Response and Request Validation ​

Validation lives in createClient(), so it is centralised rather than repeated in each domain client. Response validation is on by default; request validation is opt-in. The full guide, including schema conventions and how to extend a schema, is RUNTIME-VALIDATION.md.

mermaid
sequenceDiagram
    participant Consumer
    participant CreateClient as createClient()
    participant Handler as handleRequest
    participant ESI as ESI API
    participant ReqSchema as Request Zod Schema
    participant RespSchema as Response Zod Schema

    Consumer->>CreateClient: client.alliance.getAllianceById(id)

    alt validateRequest enabled + requestSchema defined
        CreateClient->>ReqSchema: safeParse(body)
        alt Invalid request body
            ReqSchema-->>Consumer: throw EsiValidationError(direction: 'request')
        end
    end

    CreateClient->>Handler: handleRequest(path, method, ...)
    Handler->>ESI: HTTP GET /alliances/{id}/
    ESI-->>Handler: JSON response
    Handler-->>CreateClient: { headers, body } (after response interceptors)

    alt validateResponse enabled (default)
        CreateClient->>RespSchema: safeParse(body)
        alt Valid response
            CreateClient-->>Consumer: result.data (typed)
        else Invalid response
            CreateClient-->>Consumer: throw EsiValidationError(direction: 'response')
        end
    else validateResponse disabled
        CreateClient-->>Consumer: raw body
    end
  • Loose objects. Schemas use z.looseObject(), so fields ESI adds later survive safeParse().data (DES-01).
  • Typed returns. InferEndpointResult<D> is z.infer of the response schema; cursor endpoints return CursorResult<Element>.
  • Drift. npm run schema:drift compares the hand-written schemas with the live OpenAPI spec.

11. Pagination and Streaming ​

ESI pages data two ways, and the library exposes three styles on top. The full guide, with concurrency defaults and failure semantics, is PAGINATION.md.

StyleEntry pointPath through the pipeline
Eager offsetAny paginated domain methodexecuteRequest reads x-pages, fetches the rest and returns one merged array
Streaming offsetstream* methods, BaseEsiClient.streamEndpoint()AsyncPaginationIterator.fetchPages() yields one validated page at a time via handleSinglePageRequest
Concurrent offsetfetchAll* methods, BaseEsiClient.fetchAllEndpoint()fetchAllPages() fetches page 1, then the rest in batches, via handleSinglePageRequest
CursorEndpoints with cursorPaginationcreateClient appends before / after; result is CursorResult with cursors

handleSinglePageRequest applies retry, the circuit breaker, the rate limiter and request interceptors, but not the spec-TTL cache, deduplication, cache writes or response interceptors.

mermaid
flowchart TB
    fa["for await (page of client.market.streamMarketOrders(id))"] --> se["BaseEsiClient.streamEndpoint()"]
    se --> fp["fetchPages()"]
    fp --> p1["Page 1 → read x-pages → yield"]
    p1 --> pn["Pages 2..N → yield"]
    p1 --> sp["handleSinglePageRequest"]
    pn --> sp
    sp --> retry["RetryStrategy → executeSingleFetch"]

    style fa fill:#e3f2fd,stroke:#1565c0
    style se fill:#fff3e0,stroke:#e65100
    style fp fill:#fce4ec,stroke:#c62828
    style sp fill:#eceff1,stroke:#37474f

12. Errors ​

Error
├── EsiError (statusCode, sanitised url, requestId)      .retryable ⇐ {420, 429, 502, 503, 504}, timeout, network
│   ├── TimeoutError (+ timeoutMs)
│   ├── EsiNetworkError (+ cause)
│   ├── EsiValidationError (+ ZodError, direction: request | response)   not retryable
│   ├── CircuitOpenError (endpoint, failures, retryAfterMs)              not retryable
│   └── EsiFaultError (+ code, cause)                                    not retryable
│       └── EsiConfigurationError | EsiParseError | EsiPaginationError | EsiTokenRefreshError
├── AuthError
│   ├── SsoError (+ statusCode)                           .retryable ⇐ 429 or ≥ 500
│   ├── TokenRevokedError
│   ├── TokenDecodeError
│   └── CharacterNotFoundError
└── SdeError → SdeValidationError | SdeVersionMismatchError

The auth subtree lives in src/auth/errors.ts and is exported from the root and ./errors, with guards isAuthError, isSsoError, isTokenRevoked and isCharacterNotFound.

Configuration and plumbing faults (NO_AUTH_TOKEN, CONFIGURATION_ERROR, JSON_PARSE_ERROR, PAGINATION_INCOMPLETE, TOKEN_REFRESH_FAILED, VALIDATION_ERROR) are EsiFaultError subclasses carrying that code, so every failure the pipeline raises is an EsiError and safe mode keeps its class. Guards, safe mode and the full retryability rules are in ERRORS.md.


13. Logging ​

Pipeline code logs through logInfo / logWarn / … in src/core/logger/clientLog.ts, which resolve the logger per call: the client's own logger, then the global logger from setLogger(), then the pino default at ESI_LOG_LEVEL (default warn), built on its first use and never at import (ARCH-06). ARCH-09 requires all pipeline logging to take the per-client route; the rate limiter still logs with no client handle and so reaches the global logger. ROADMAP Phase 4 migrates the remaining call sites, moves URL sanitising to the logger boundary, and adds a lint that forbids the global logger import inside src/core/requestPipeline. The Logger port in src/core/ports/ has the same six methods as ILogger. See LOGGING.md.


14. Code Generation ​

Five generated artefacts come from two generators (ARCH-01). npm run generate:types (scripts/spec/generate-esi-types.ts) fetches the ESI OpenAPI document at COMPATIBILITY_DATE (or --latest, --compatibility-date=, --spec-file=) and writes four:

ArtefactConsumed by
src/types/generated/esi-spec.generated.tsThe EsiSpec type namespace and spec-alignment.check.ts
src/core/endpoints/esi-cache-ttls.generated.tsSpec-aware caching (lookupSpecTtl)
src/core/endpoints/esi-rate-limit-groups.generated.tsRateLimiter group buckets
src/core/endpoints/esi-scopes.generated.tsvalidate:auth-scopes, scope lookups for consumers

npm run spec:generate (scripts/spec/spec-generate.ts, with scripts/spec/spec-scope-tree.ts for the tree) never touches the network. It reads the vendored snapshot tests/contract/snapshots/esi-openapi.snapshot.json and writes the fifth:

ArtefactConsumed by
src/generated/operations.generated.tssrc/client/ (esi.public and esi.as(identity), §1a) and the PipelineTransport tests

The snapshot changes in one place: spec-refresh.yml, run by a push to a spec-refresh/** branch or by manual dispatch, re-vendors the document at COMPATIBILITY_DATE, runs spec:generate and spec:coverage, runs generate:types against the new snapshot, re-records the public payload fixtures, and commits the result for review.

mermaid
flowchart TB
    spec["ESI OpenAPI spec"] --> gen["generate:types"]
    gen --> types["esi-spec.generated.ts"]
    gen --> ttls["esi-cache-ttls.generated.ts"]
    gen --> rates["esi-rate-limit-groups.generated.ts"]
    gen --> scopes["esi-scopes.generated.ts"]

    snap["Vendored snapshot<br/>(spec-refresh.yml)"] --> specgen["spec:generate"]
    specgen --> ops["operations.generated.ts"]
    ops --> check["spec:generate:check"]
    ops --> cov["spec:coverage"]

    types --> fresh["git diff freshness"]
    types --> align["spec-alignment type assertions"]
    scopes --> auth["validate:auth-scopes"]
    spec --> drift["schema:drift vs hand-written Zod"]

    style spec fill:#f5f5f5,stroke:#999
    style gen fill:#e3f2fd,stroke:#1565c0
    style snap fill:#f5f5f5,stroke:#999
    style specgen fill:#e3f2fd,stroke:#1565c0
CheckMechanismWhat it catches
Generated freshnessgit diff --exit-code on src/types/generated/ and every src/core/endpoints/esi-*.generated.ts after generate:types, in ci.yml, release.yml and the nightlyStale generated types, TTLs, rate-limit groups or scopes after an ESI spec change
Generated operationsspec:generate:check and spec:coverage in ci.yml lint-and-build; spec:generate:check in release.ymlAn edited or stale operations.generated.ts, or an operation missing from it
Auth / scope cross-validationscripts/spec/validate-auth-scopes.tsrequiresAuth disagreeing with the scope map in either direction (DES-04)
Spec-alignment assertionsAssertTrue<HasAllSpecKeys<SpecType, ZodType>> at compile timeA hand-written schema missing a field the spec defines
Schema driftnpm run schema:driftHand-written schemas diverging from the spec's field names and types

Generated files are never edited by hand (DES-03). Where each check runs in CI is in QUALITY-GATES.md; supply-chain controls such as SHA-pinned actions and npm provenance are in SECURITY.md; the test tiers are in TESTING.md.

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