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:
| Topic | Canonical guide |
|---|---|
| Naming, endpoint definitions, adding a client | DESIGN-RULES.md |
| Error classes, guards, retryability, safe mode | ERRORS.md |
ILogger, per-client loggers, levels | LOGGING.md |
Offset and cursor pagination, stream*, fetchAll* | PAGINATION.md |
| Zod schemas and validation options | RUNTIME-VALIDATION.md |
| Runtime defences and supply chain | SECURITY.md |
| Test tiers | TESTING.md |
| The SDE module and how it complements the client | SDE.md |
| CI workflows and gates | QUALITY-GATES.md |
| Releases | RELEASE.md |
| The 11.0 plan, phase by phase | ROADMAP.md |
| Major, minor or patch; breaking-change markers | SEMVER.md |
C4 Model
C4 Level 1 — System Context
System context showing ESI.ts in its operating environment.
| Element | Description |
|---|---|
| Consumer Application | Node.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.ts | TypeScript SDK — auth, caching, rate limiting, circuit breaking, pagination, validation |
| EVE Online ESI API | CCP's REST API at esi.evetech.net, secured by EVE SSO (OAuth2) |
| EVE SSO | OAuth2 authorisation server — issues and refreshes access tokens |
| ESI OpenAPI Spec | Machine-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 |
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:5C4 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.
| Container | Where | Purpose |
|---|---|---|
| Construction | src/EsiClient.ts, src/EsiClientBuilder.ts | EsiClient, EsiClientBuilder → CustomEsiClient, EsiApiFactory. All three call configureApiClient(). ApiClientBuilder (src/core/ApiClientBuilder.ts) builds a bare ApiClient and does not (§9) |
| Domain clients | src/clients/ (one class per ESI domain + BaseEsiClient) | Named methods, stream* and fetchAll* wrappers, withMetadata() and withSafeMode() views. No HTTP knowledge |
| Endpoint definitions | src/core/endpoints/*Endpoints.ts | Declarative maps of path, method, auth, pagination kind and schemas. createClient() turns a map into typed methods |
| Schemas | src/schemas/ | Hand-written Zod schemas for runtime validation; return types are inferred from them |
| Request pipeline | src/core/ApiRequestHandler.ts, src/core/requestPipeline/ | Pure functions: cache policy, headers, fetch execution, status handling, pagination, middleware bridge, dependency resolution |
| Resilience | src/core/ (RetryStrategy, RateLimiter, CircuitBreaker, RequestDeduplicator, ETagCacheManager) | Each behind an interface with a setter on ApiClient |
| Transport | ApiClient.getFetch() | globalThis.fetch unless replaced with setFetch(). Timeout by AbortController |
| Generated metadata | src/types/generated/, src/core/endpoints/esi-*.generated.ts | Types, cache TTLs, rate-limit groups and scopes generated from the ESI OpenAPI spec by generate:types; CI verifies freshness |
| Generated operations | src/generated/operations.generated.ts | 235 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) |
| Ports | src/core/ports/ | Seven type-only interfaces: CacheStore, Clock, HttpTransport, Identity, Logger, OperationTransport, TokenProvider. They import nothing. Exported from ./client (§1a) |
| Adapters | src/adapters/ | PipelineTransport implements OperationTransport over handleRequest and fetchPages, so a generated operation shares the ApiClient's budgets and cache. Internal; ./client wraps it (§1a) |
| Auth | src/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/memory | Subpath 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) |
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:#aaaC4 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.
| Component | File | Exports |
|---|---|---|
| handleRequest | ApiRequestHandler.ts | Spec-TTL cache check, retry wrapping, a spec-TTL re-check on each retry attempt, deduplication keyed by buildDedupeKey; delegates to executeRequest |
| handleSinglePageRequest | ApiRequestHandler.ts | One page with retry and no If-None-Match, used by stream* and fetchAll* (see PAGINATION.md) |
| executeRequest | ApiRequestHandler.ts | Fetch; write invalidation on 201/204; the 201 body; emptyWhenNoContent; the 304 re-run; status handling; text or JSON body; cache write; pagination; response interceptors |
| dependencies | dependencies.ts | resolveCache, resolveRateLimiter, resolveCircuitBreaker, resolveRetryStrategy |
| headers | headers.ts | buildRequestHeaders, parseCacheControlTtl |
| cachePolicy | cachePolicy.ts | lookupSpecTtl, trySpecAwareCacheHit, hasCachedEntry, tryStaleCacheResponse, cacheResponse, currentWriteGeneration, invalidateAfterWrite, evictRejectedResponse |
| statusHandling | statusHandling.ts | handleEarlyStatus (201/204/304), handleErrorResponse (4xx/5xx), readEsiErrorReason, statusMessage, STATUS_MESSAGES, wrapError |
| middlewareBridge | middlewareBridge.ts | applyRequestMiddleware, applyResponseInterceptors |
| fetchExecution | fetchExecution.ts | executeSingleFetch, fetchOnePage, parseJsonBody |
| pagination | paginationOrchestration.ts | handleCursorPagination, handleOffsetPagination |
| cacheKey | src/core/cache/cacheKey.ts | buildCacheKey (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.
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:#2e7d32C4 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.
| Component | Interface | Purpose |
|---|---|---|
| RetryStrategy | IRetryStrategy | Exponential backoff with jitter on retryable EsiErrors; one 401 token refresh per call |
| RequestDeduplicator | IDeduplicator | Coalesces identical in-flight GETs without a body, keyed by endpoint and caller identity |
| CircuitBreaker | ICircuitBreaker | Opt-in per-key state machine (closed / open / half-open); key by resolved path or template |
| RateLimiter | IRateLimiter | Per-group buckets from the generated spec, learns from response headers, blocks a group on 420/429 |
| ETagCacheManager | ICache | Spec-TTL hits, If-None-Match conditionals, stale fallback on 5xx |
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:#2e7d321. Layers
Dependency direction flows inward. Outer layers depend on inner layers, never the reverse.
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<D>"]
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:#283593Design 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 callsconfigureApiClient(), and that is where the surfaces can drift (§9). - Typed returns from schemas.
InferEndpointResult<D>appliesz.infer<>to the endpoint'sresponseSchema. An endpoint without a schema returnsunknown(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 plusBaseEsiClientinsrc/clients/; 39*Endpoints.tsmaps wiring 235 endpoints; 36 domain schema modules pluscommon.tsandesiEnum.tsinsrc/schemas/; 235 generated operations. The endpoint and operation counts differ because the hand-written maps and the generated file are separate trees, reconciled byschema:driftandspec: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.
| Port | Shape | What implements it today |
|---|---|---|
CacheStore | get, set(key, etag, data, headers, ttlMs?), delete, clear, shutdown over CachedResponse | EsiClientConfig.cache and EsiOptions.cache take one; CacheStoreCache adapts it to the wider ICache (getETag, has, deleteByPath, getStats) that ETagCacheManager implements |
Clock | now(): 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 |
Logger | fatal, error, warn, info, debug, trace with optional fields | Structurally the same as ILogger, so the pino default and any ILogger fit |
OperationTransport | request<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 |
Identity | characterId?, 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, soGET /characters/{character_id}/wallet/journalistree.character(id).wallet.journal.get().PublicScopeTree: the same arrangement holding only the operations whosescopesare empty. An authenticated operation is absent from the type, so calling one does not compile.createScopeTree(transport): builds aScopeTreeover 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.
createClientvalidates against the hand-written schemas insrc/schemas/;PipelineTransportvalidates againstsrc/adapters/responseSchemas.generated.ts, one schema per operation generated from the same document as the types, whencreateEsiis givenvalidateResponse: true. Unset orfalse, the body is returned unvalidated, as before; the default forcreateEsiwill become validating in a future major. The runtime passesvalidateResponse: truetoPipelineTransportonly for an explicittrue, because the sharedApiClientdefault (true) is what keeps the hand-written clients validating. A body that fails throwsEsiValidationErrorand its cache entry is evicted, as increateClient. Where ESI's wire responses break the document,scripts/spec/response-schema-overrides.jsonwidens the generated schema (the operation's TypeScript type keeps the document's shape until the next major, since widening it is a major change), andtests/tdd/adapters/responseSchemas.recorded.test.tsruns 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.
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 specThe 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().
| Importer | May 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.
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:#37474fSolid 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.
| Work | What changes in this architecture |
|---|---|
| Phase 3 · layer baseline to empty | Done: ClientRegistry.ts is in src/clients/, EsiClientConfig in src/core/; BASELINE is {}. |
| Phase 4 · logging and import-time behaviour | URL 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 22 | engines.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.
- Domain method →
createClientclosure (src/core/endpoints/createClient.ts). Logs a deprecation warning if the definition carriesdeprecated.buildEndpointPathassembles the path, query and body, and appendsdatasourcewhen one is set. The request body is validated againstrequestSchemaonly whenvalidateRequestis on; failure throwsEsiValidationErrorwithdirection: 'request'. - 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. - 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. - 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. 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.- Status handling (
executeRequest, thenstatusHandling.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 (getPublicContractBidsandgetPublicContractItems, where ESI answers an expired contract with a 200 andContent-Length: 0), such a 200 resolves with no body, andcreateClientturns that, or a 204, into[]. Elsewhere an empty 200 body is aJSON_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),
executeRequestruns again; the repeat finds no entry, sends noIf-None-Matchand 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 returnsundefined. - 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 onlymeta.getOpenApiYaml) reads the body as text; every other endpoint parses JSON.
- 201 or 204 on a non-GET first runs
- 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:
handleOffsetPaginationcaches it once, after every page is merged. A non-GET that reaches this step invalidates the path. Cursor pagination readsx-cursor-before/x-cursor-after; offset pagination followsx-pagesand merges the pages. - Response interceptors → Zod validation → envelope. Interceptors see the assembled response. When
validateResponseis on (the default) the body is replaced bysafeParse().data, or the cached copy is evicted (evictRejectedResponse) andEsiValidationErroris thrown.withMetadata()andwithSafeMode()wrap the result last.
Request headers (buildRequestHeaders in requestPipeline/headers.ts):
| Header | When | Value |
|---|---|---|
Accept | Always | application/json, or the endpoint's textResponse.accept (meta.getOpenApiYaml asks for YAML) |
Accept-Encoding | Always | gzip, deflate, br |
User-Agent | Always | <userAgent> @lgriffin/esi.ts/<version> when a userAgent is configured, else the library identifier alone |
X-Compatibility-Date | Always | The client's compatibilityDate, else COMPATIBILITY_DATE (2026-08-18) from src/core/constants.ts |
X-User-Agent | When there is a value | The configured userAgent, else the clientId if it is a legal header value |
X-Tenant | When tenant is set | The tenant |
Accept-Language | When language is set | The language |
Authorization | Only when requiresAuth | Bearer <token>; with no token configured the call throws NO_AUTH_TOKEN before any network I/O |
If-None-Match | GET, cache on, ETag stored | The stored ETag for this identity's cache key |
Content-Type | When there is a body | application/json |
Happy Path
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 dataError and Edge-Case Handling
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
end3. 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:
| Dependency | Setter on ApiClient | Default from configureApiClient | Resolution when absent |
|---|---|---|---|
ICache | setCache() | ETagCacheManager unless enableETagCache: false | resolveCache() → null: no ETag, spec-TTL or stale caching |
IRateLimiter | setRateLimiter() | RateLimiter, always | resolveRateLimiter() throws CONFIGURATION_ERROR |
ICircuitBreaker | setCircuitBreaker() | CircuitBreaker only if enableCircuitBreaker | resolveCircuitBreaker() → null: no circuit breaking |
IRetryStrategy | setRetryStrategy() | none; retryConfig of 3 retries, 1 s, 30 s | resolveRetryStrategy() → new RetryStrategy(retryConfig) |
IDeduplicator | setDeduplicator() | RequestDeduplicator unless disabled | read directly; null means no coalescing |
FetchLike | setFetch() | none | globalThis.fetch |
ILogger | setLogger() | only when logger or logLevel is configured | global 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:
| Setting | Setter | Set by | Effect |
|---|---|---|---|
tenant | setTenant() | configureApiClient | X-Tenant; refused when not a legal header value |
userAgent | setUserAgent() | configureApiClient | Prefixes User-Agent, sets X-User-Agent; validated in the setter |
compatibilityDate | setCompatibilityDate() | All three surfaces | X-Compatibility-Date |
language | setLanguage() | each surface | Accept-Language |
datasource | setDatasource() | each surface | datasource query parameter |
onTokenRefresh | setTokenProvider() | each surface | 401 refresh (§5) |
accessToken | setAccessToken() | the ApiClient constructor | Authorization on requiresAuth endpoints |
validateResponse / validateRequest | setValidateResponse() / setValidateRequest() | configureApiClient | Zod validation (§10) |
timeout | setTimeout() | configureApiClient | Per-request AbortController timeout, default 30 s |
incompletePages | setIncompletePages() | configureApiClient | Truncate (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.
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:#2e7d32Middleware inventory
| Concern | Interface | Default | Key defaults |
|---|---|---|---|
| Rate limiter | IRateLimiter | on, mandatory | Buckets from generated groups; 420/429 → 60 s block; 50 ms minimum spacing |
| Circuit breaker | ICircuitBreaker | off, opt-in | 5 failures, 30 s reset, 1 half-open probe, key by resolved path |
| Deduplicator | IDeduplicator | on | GET without body only |
| Retry | IRetryStrategy | on | 3 retries, 1 s base, 30 s cap, GET only |
| ETag cache | ICache | on | 1000 entries, 5 min default TTL, 60 s sweep |
| Interceptors | RequestInterceptor / ResponseInterceptor | none | Sequential, unsubscribe closure returned |
| Transport | FetchLike | global fetch | 30 s timeout |
| Logger | ILogger | pino, level warn | Per-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:
- 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. - Deletes every cache entry whose key contains the path (
ICache.deleteByPath). - Bumps the cache's write generation and records the path. A GET takes the generation before it is sent (
currentWriteGeneration);cacheResponsedeclines 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.
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' | undefinedSet 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.
| Rule | Behaviour |
|---|---|
| What is retried | An EsiError whose retryable is true: status 0 (network or timeout), 420, 429, 502, 503, 504 |
| Which methods | GET only, unless retryMutations: true |
| Delay | baseDelayMs × 2^attempt × jitter(0.75–1.25), capped at maxDelayMs |
| Defaults | configureApiClient sets maxRetries: 3, baseDelayMs: 1000, maxDelayMs: 30000; retryAttempts overrides only the count |
| Token refresh | One 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-check | From the second attempt on, handleRequest checks the spec-TTL cache before sending; a copy stored during the backoff is served |
| Refresh failure | Rethrown as-is if it is an EsiError, otherwise an EsiTokenRefreshError with the original on cause |
| Circuit open | CircuitOpenError 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):
- 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. - Legacy error limit. If
x-esi-error-limit-remainis 10 or fewer, wait up to 5 s; if it is exhausted, wait forx-esi-error-limit-reset. - 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. - 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.
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().
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:#2e7d327. 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.
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 noteWhere 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):
| Option | Default | Description |
|---|---|---|
failureThreshold | 5 | Consecutive failures before opening |
resetTimeoutMs | 30000 | Time after the last failure before a request may probe |
halfOpenMaxAttempts | 1 | Probe admissions counted in half-open |
keyStrategy | 'resolved' | How requests map to circuits; see below |
staleThresholdMs | 3600000 | Age after which a closed, zero-failure circuit is removed by cleanup() |
cleanupIntervalMs | disabled | Runs cleanup() on an unref'd timer when set above 0; staleThresholdMs is a sensible value |
Keying.
| Strategy | Key | Effect |
|---|---|---|
'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.
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 circuitsCircuitOpenError 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.
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.
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.
| Rule | Behaviour |
|---|---|
| Order | Registration order. Each interceptor is awaited and receives the previous one's return value |
| Request interceptors run | On every HTTP attempt: each retry and each page. After header construction, before the circuit breaker and rate limiter |
| Request changes applied | url, headers and body. method and endpoint are informational |
| Response interceptors run | Once per handleRequest call on the assembled result, including spec-TTL and stale cache hits, after offset pages are merged |
| Response changes applied | headers and body. Zod validation runs after, on the modified body |
| Not run | Response interceptors do not run when the call throws, or on the per-page path used by stream* and fetchAll* |
| Cost when unused | Both 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.
| Pattern | Use case | What you get |
|---|---|---|
| EsiClient | Most consumers | Every domain client as a lazy getter (client.market, client.alliance, …) |
| EsiClientBuilder | Selective | A CustomEsiClient with only the clients you add, and fluent configuration |
| EsiApiFactory | Single-domain scripts | One 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.
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:#37474f10. 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.
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 survivesafeParse().data(DES-01). - Typed returns.
InferEndpointResult<D>isz.inferof the response schema; cursor endpoints returnCursorResult<Element>. - Drift.
npm run schema:driftcompares 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.
| Style | Entry point | Path through the pipeline |
|---|---|---|
| Eager offset | Any paginated domain method | executeRequest reads x-pages, fetches the rest and returns one merged array |
| Streaming offset | stream* methods, BaseEsiClient.streamEndpoint() | AsyncPaginationIterator.fetchPages() yields one validated page at a time via handleSinglePageRequest |
| Concurrent offset | fetchAll* methods, BaseEsiClient.fetchAllEndpoint() | fetchAllPages() fetches page 1, then the rest in batches, via handleSinglePageRequest |
| Cursor | Endpoints with cursorPagination | createClient 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.
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:#37474f12. 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 | SdeVersionMismatchErrorThe 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:
| Artefact | Consumed by |
|---|---|
src/types/generated/esi-spec.generated.ts | The EsiSpec type namespace and spec-alignment.check.ts |
src/core/endpoints/esi-cache-ttls.generated.ts | Spec-aware caching (lookupSpecTtl) |
src/core/endpoints/esi-rate-limit-groups.generated.ts | RateLimiter group buckets |
src/core/endpoints/esi-scopes.generated.ts | validate: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:
| Artefact | Consumed by |
|---|---|
src/generated/operations.generated.ts | src/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.
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| Check | Mechanism | What it catches |
|---|---|---|
| Generated freshness | git 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 nightly | Stale generated types, TTLs, rate-limit groups or scopes after an ESI spec change |
| Generated operations | spec:generate:check and spec:coverage in ci.yml lint-and-build; spec:generate:check in release.yml | An edited or stale operations.generated.ts, or an operation missing from it |
| Auth / scope cross-validation | scripts/spec/validate-auth-scopes.ts | requiresAuth disagreeing with the scope map in either direction (DES-04) |
| Spec-alignment assertions | AssertTrue<HasAllSpecKeys<SpecType, ZodType>> at compile time | A hand-written schema missing a field the spec defines |
| Schema drift | npm run schema:drift | Hand-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.
