Plan 005 — GW2 API v2 client
Status: approved Written in plan mode from spec.md and research.md. Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.
Produced alone — tasks.md stays untouched until this plan is approved in turn.
Goal
The api gains one injectable, process-wide client that is the only way any code reaches the external GW2 API. It reads items, recipes, recipe-search, and prices; it stays under the 300/5-per-sec budget by construction; it batches multi-id reads, caches static data hard and prices briefly, and validates every response before returning it. After this plan, a future service can read GW2 data safely by injecting one provider — and no service can reach GW2 any other way. No product feature, no HTTP route, no account access is built.
Approach
Two layers, matching spec R1: a framework-agnostic core (Gw2Client, a plain class — no Nest, no decorators) wrapped by a thin Nest @Injectable() singleton (Gw2Service) that constructs one Gw2Client and delegates to it. The core holds all the logic and is unit-tested directly with an injected fetch and Vitest fake timers; the wrapper is verified only for DI resolution, exactly as HealthService is today.
The core composes four small, independently testable units, built bottom-up:
TokenBucket— capacity 300, refill 5/sec.take()resolves immediately when a token is free, otherwise schedules resolution viasetTimeoutat the refill rate. This is the proactive budget (spec R3); every outbound request awaitstake()first, so overflow is structurally impossible.BoundedCache<V>— a size-capped map with optional per-entry TTL.get/set; on overflow it evicts the oldest entry (spec R6 — bounded, no unboundedMap). Static entries are stored with no expiry; price entries with a 60 000 ms TTL.- Zod schemas + typed errors — one schema per endpoint validating only the fields we depend on (research V4); Zod strips unknown keys by default, which is R7's "tolerate unknown extras." Typed errors:
Gw2ValidationError,Gw2RateLimitError,Gw2RequestError. Gw2Client— composes the above. Each public method (items,recipes,searchRecipes,prices) does: split requested ids into cache-hits and misses → chunk misses to ≤199 per request (research V3, the effective cap — not the documented 200) → for each chunkawait bucket.take(), fetch, retry on429→ validate → cache per id → reassemble in requested order, omitting ids the API didn't return.searchRecipesis keyed and cached by its query (input/outputid), not per id.
Request semantics from research baked into the client: treat both 200 and 206 as success (F4 — a 206 means some ids didn't exist and are simply absent); treat a 404 on a batch as "all ids in this chunk are unknown" → contribute nothing, don't throw (F4); on 429, retry with bounded exponential backoff + jitter as the primary recovery, honoring Retry-After only if it happens to be present (V2 — it is undocumented); any other non-ok status throws Gw2RequestError. The x-rate-limit-limit: 600 response header is ignored — the bucket is hard-coded to the documented 300 (F6).
Caching is per id (per query for search), so an overlapping later read reuses earlier results. A cold read of N ids therefore issues exactly ⌈N / 199⌉ requests (SC3); a fully-warm repeat issues zero (SC4).
Architecture
apps/api/src/gw2/
token-bucket.ts TokenBucket ─┐
bounded-cache.ts BoundedCache<V> ─┼─ pure, no Nest, no network
gw2.errors.ts typed error classes │
gw2.schemas.ts Zod schemas + types │
gw2-client.ts Gw2Client ────────────┘ composes the four above; takes an injected fetch
gw2.service.ts Gw2Service @Injectable singleton → constructs & delegates to one Gw2Client
gw2.module.ts Gw2Module provides Gw2Service
apps/api/src/app.module.ts imports Gw2Module (one-line modification)Dependency direction is one-way: app.module → Gw2Module → Gw2Service → Gw2Client → the four units. Nothing below Gw2Client knows Nest exists; nothing knows about HTTP routes (there are none).
Tech stack
- NestJS 11 (
@nestjs/common11.1.28) — the@Injectable()/@Module()wrapper, matching the api. - Zod 4.4.3 — response validation. Already a dependency (
apps/api/package.json); no addition. - Node 26 global
fetch— the HTTP call. Injected intoGw2Clientas afetchFnparameter (defaultglobalThis.fetch) so tests supply a fake and CI never touches the network (spec R8). - Vitest 4 with fake timers (
vi.useFakeTimers+advanceTimersByTimeAsync) — drives the bucket refill and cache TTLs deterministically. Already the api's test runner. - SWC build — already compiles
apps/api/src/**;src/gw2/**is covered with no config change.
New dependencies: none. TokenBucket (~40 lines) and BoundedCache (~40 lines) are hand-rolled rather than pulling bottleneck/p-limit/lru-cache: each generic library would need adapting to GW2's exact 300-burst/5-refill semantics anyway, and "no new dependency without justification" is a Global Constraint. This is the justification for not adding one.
Global Constraints
Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them — a test asserts they appear here unchanged.
From docs/architecture/typescript.md:
- No
any. Not in app code, not in tests. Useunknownplus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why. - No non-null assertions (
!) to silence the compiler. - No
@ts-expect-errorwithout a comment explaining what is expected and when it can be removed. - Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
- Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
- Match the style of surrounding code. No new dependency without justification in the spec or plan.
moduleResolution: "node"andbaseUrlare removed in TS 7. Use"nodenext"(or"bundler"for Vite/bundler-resolved code) pluspaths: { "*": ["./*"] }in place ofbaseUrl.- TypeScript's default
libincludes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), ordocument/windowresolve everywhere, silently defeating profile isolation. A profile that needs DOM adds"DOM","DOM.Iterable"on top of that floor. experimentalDecoratorsandemitDecoratorMetadataare supported for typecheck only —tsc --noEmitaccepts decorator syntax and resolves metadata types, but--noEmitnever emitsdesign:paramtypesat runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; seestack.md's api build model.
From docs/architecture/stack.md:
- Monorepo, pnpm workspaces.
apps/api— NestJS (TypeScript).apps/web— React (TypeScript).packages/*— shared code (domain types, the curated Mystic Forge dataset) when sharing is real, not speculative.- Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
- Vitest everywhere, both apps.
- Deploy: managed PaaS (Fly.io / Railway). CI: GitHub Actions — lint + typecheck + test + build.
- Static data (items, station recipes) is immutable: cache hard.
- Prices are volatile: short TTL, recomputed live.
- GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec,
429on overflow. Batch up to 200 ids per?ids=call. - All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
- API keys are user secrets: encrypted at rest, never logged, never returned to the client.
From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.
Implementation note on the batch cap. The verbatim constraint above quotes
stack.md's "up to 200 ids", which is the documented figure.research.mdV3 found the server actually rejects at 200; the implementation chunks at ≤199. The constraints block is left unedited on purpose (a test asserts it matches the doc); the researched 199 governs the code.
File Structure
Exact paths, and what each file is responsible for. A path here is a commitment; a task that touches a file not listed is a signal the plan missed something.
| Path | Change | Responsibility |
|---|---|---|
apps/api/src/gw2/token-bucket.ts | new | TokenBucket — proactive 300/5-per-sec budget; take() gates every request (R3). |
apps/api/src/gw2/token-bucket.test.ts | new | Burst-then-refill behavior under fake timers (SC2). |
apps/api/src/gw2/bounded-cache.ts | new | BoundedCache<V> — size-capped store, optional per-entry TTL, oldest-out eviction (R6). |
apps/api/src/gw2/bounded-cache.test.ts | new | TTL expiry and size-cap eviction under fake timers (SC5, SC6). |
apps/api/src/gw2/gw2.errors.ts | new | Gw2ValidationError, Gw2RateLimitError, Gw2RequestError. |
apps/api/src/gw2/gw2.schemas.ts | new | Zod schemas + inferred types for item, recipe, recipe-search, price (research V4). |
apps/api/src/gw2/gw2.schemas.test.ts | new | Valid fixture parses; missing/mistyped field throws; extra field tolerated (SC7). |
apps/api/src/gw2/gw2-client.ts | new | Gw2Client core — batching, per-id cache, 429 retry, validation, reassembly. |
apps/api/src/gw2/gw2-client.test.ts | new | Batching (SC3), cache hits (SC4), price TTL (SC5), 206/404/429 handling (SC8, F4), offline (SC9). |
apps/api/src/gw2/gw2.service.ts | new | Gw2Service — @Injectable() singleton wrapping one Gw2Client; delegates the four reads. |
apps/api/src/gw2/gw2.module.ts | new | Gw2Module — provides & exports Gw2Service. |
apps/api/src/gw2/gw2.service.test.ts | new | Service resolves from a compiled Nest module as a singleton (SC1, P1 #1). |
apps/api/src/gw2/__fixtures__/*.json | new | Live GW2 JSON captured 2026-07-27: items.json, recipes.json, recipe-search.json, prices.json. |
apps/api/src/app.module.ts | modified | Add Gw2Module to imports (one line). |
Data & contracts
Zod schemas validate only the depended-on fields (research V4); unknown keys are stripped, not errored.
Gw2Item—{ id: number; name: string; type: string; rarity: string; flags: string[]; vendor_value: number }.Gw2Recipe—{ id: number; type: string; output_item_id: number; output_item_count: number; disciplines: string[]; min_rating: number; flags: string[]; ingredients: { item_id: number; count: number }[] }.Gw2RecipeSearchResult—number[](a bare array of recipe ids).Gw2Price—{ id: number; whitelisted: boolean; buys: { quantity: number; unit_price: number }; sells: { quantity: number; unit_price: number } }.unit_priceis copper.
No HTTP contract changes: openapi.json, the Orval-generated web client, and apps/web are untouched (spec R10). These types are internal to the api and are not exposed on any route.
Test strategy
Every test is offline: Gw2Client is constructed with a fake fetchFn returning Response-like objects built from the committed fixtures, and fake timers drive all time-dependent behavior. No test provides a real fetch, which is the proof of SC9. Traceability names each test after its criterion (SC3: …, P1 #2: …) so the spec's table can be filled during implementation.
- SC1 / P1 #1 (DI singleton) — compile a Nest module and assert
Gw2Serviceresolves, and that two resolutions return the same instance. Mirrorshealth.controller.test.ts. - SC2 / P1 #2 (budget) — enqueue > 300
take()calls; assert 300 resolve synchronously and the remainder resolve only as fake time advances at 5/sec; the count dispatched in any 1 s window never exceeds the budget. - SC3 / P1 #3 (batching) — request N = 250 ids from a cold cache; assert
fetchFnwas called exactly ⌈250 / 199⌉ = 2 times and the result covers every id the fixture returns. - SC4 / P1 #4 (static cache) — request the same item id twice; assert
fetchFncalled once. - SC5 / P1 #5 (price TTL) — request a price twice within 60 s → one call; advance fake time past 60 s, request again → second call.
- SC6 (bounded) — insert more distinct entries than the cap; assert size never exceeds the cap and the oldest key is evicted.
- SC7 / P1 #6 (validation) — a fixture missing/ mistyping a depended-on field throws
Gw2ValidationError; a fixture with an extra unknown field returns successfully. - SC8 / P1 #7 (429) —
fetchFnreturns429then200: the client retries and returns the success;429on every attempt within the bound throwsGw2RateLimitError. - F4 coverage — a
206fixture with a missing id returns the present ids and omits the absent one; a404chunk contributes nothing without throwing. - SC9 (offline) — the whole suite runs with no network access; guaranteed structurally by injecting
fetchFnin every test.
Not tested directly: whether the live API still matches the fixtures (a live smoke test is out of scope, spec + research V-graduation) — human/periodic verification stands in. Whether real GW2 returns Retry-After on a live 429 (V2) — the code path that honors it is unit-tested with a synthetic Retry-After, but the live behavior is not exercised.
Alternatives considered
- A rate-limit / cache library (
bottleneck,p-limit,lru-cache) — rejected: each is a new dependency for ~40 lines, and none models GW2's 300-burst/5-refill bucket without configuration we'd write anyway. YAGNI + the no-new-dependency constraint. - Per-request caching (key the whole id-list) — rejected: misses overlap reuse and makes SC4 depend on identical id ordering. Per-id caching is barely more code and strictly better.
- Injecting a clock abstraction into
TokenBucket/BoundedCache— rejected: Vitest fake timers already controlsetTimeout/Date.nowglobally, so real timers plus fake-timer tests suffice. - Extracting to
packages/gw2-api— rejected per spec R1/Q2: one consumer, speculative structure; the plain-class core already gives the isolation a package would. - Chunking at the documented 200 — rejected: research V3 measured a hard
400at 200; 199 is the real cap.
Risks
- Async token bucket ↔ fake timers. Promises resolved via
setTimeoutneedadvanceTimersByTimeAsync(not the sync variant) or tests hang. Mitigation: the bucket resolves strictly throughsetTimeout; tests use the async advance helper; called out here so the task author expects it. - Fixture drift. Captured 2026-07-27; a game patch can change static shapes. Mitigation: fixtures are dated and small; strict validation fails loudly if the real shape ever diverges from them; a live smoke test is a named future follow-up.
206mis-handled as an error. A naïveres.okcheck treats206as ok (it is), but a naïvestatus === 200check would not. Mitigation: an explicitstatus === 200 || status === 206success test, with a206fixture in the suite.- Eviction policy correctness. Oldest-out eviction must not drop a still-wanted hot entry mid-batch. Mitigation: the cap is set well above any single batch (≥ a few thousand) and SC6 asserts the bound.
Open questions
None blocking — research.md is complete and every [NEEDS VERIFICATION] has a verdict. Two implementation-detail defaults, decidable by the implementer without a gate (recorded so they're not mistaken for oversights):
- Cache size cap value — proposed 10 000 entries per store (comfortably above any batch; bounded).
- Retry bound — proposed max 4 attempts with base 250 ms backoff, capped, jittered.