Skip to content

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:

  1. TokenBucket — capacity 300, refill 5/sec. take() resolves immediately when a token is free, otherwise schedules resolution via setTimeout at the refill rate. This is the proactive budget (spec R3); every outbound request awaits take() first, so overflow is structurally impossible.
  2. BoundedCache<V> — a size-capped map with optional per-entry TTL. get/set; on overflow it evicts the oldest entry (spec R6 — bounded, no unbounded Map). Static entries are stored with no expiry; price entries with a 60 000 ms TTL.
  3. 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.
  4. 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 chunk await bucket.take(), fetch, retry on 429 → validate → cache per id → reassemble in requested order, omitting ids the API didn't return. searchRecipes is keyed and cached by its query (input/output id), 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/common 11.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 into Gw2Client as a fetchFn parameter (default globalThis.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. Use unknown plus 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-error without 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" and baseUrl are removed in TS 7. Use "nodenext" (or "bundler" for Vite/bundler-resolved code) plus paths: { "*": ["./*"] } in place of baseUrl.
  • TypeScript's default lib includes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), or document/window resolve everywhere, silently defeating profile isolation. A profile that needs DOM adds "DOM", "DOM.Iterable" on top of that floor.
  • experimentalDecorators and emitDecoratorMetadata are supported for typecheck only — tsc --noEmit accepts decorator syntax and resolves metadata types, but --noEmit never emits design:paramtypes at runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; see stack.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, 429 on 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.md V3 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.

PathChangeResponsibility
apps/api/src/gw2/token-bucket.tsnewTokenBucket — proactive 300/5-per-sec budget; take() gates every request (R3).
apps/api/src/gw2/token-bucket.test.tsnewBurst-then-refill behavior under fake timers (SC2).
apps/api/src/gw2/bounded-cache.tsnewBoundedCache<V> — size-capped store, optional per-entry TTL, oldest-out eviction (R6).
apps/api/src/gw2/bounded-cache.test.tsnewTTL expiry and size-cap eviction under fake timers (SC5, SC6).
apps/api/src/gw2/gw2.errors.tsnewGw2ValidationError, Gw2RateLimitError, Gw2RequestError.
apps/api/src/gw2/gw2.schemas.tsnewZod schemas + inferred types for item, recipe, recipe-search, price (research V4).
apps/api/src/gw2/gw2.schemas.test.tsnewValid fixture parses; missing/mistyped field throws; extra field tolerated (SC7).
apps/api/src/gw2/gw2-client.tsnewGw2Client core — batching, per-id cache, 429 retry, validation, reassembly.
apps/api/src/gw2/gw2-client.test.tsnewBatching (SC3), cache hits (SC4), price TTL (SC5), 206/404/429 handling (SC8, F4), offline (SC9).
apps/api/src/gw2/gw2.service.tsnewGw2Service — @Injectable() singleton wrapping one Gw2Client; delegates the four reads.
apps/api/src/gw2/gw2.module.tsnewGw2Module — provides & exports Gw2Service.
apps/api/src/gw2/gw2.service.test.tsnewService resolves from a compiled Nest module as a singleton (SC1, P1 #1).
apps/api/src/gw2/__fixtures__/*.jsonnewLive GW2 JSON captured 2026-07-27: items.json, recipes.json, recipe-search.json, prices.json.
apps/api/src/app.module.tsmodifiedAdd 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_price is 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 Gw2Service resolves, and that two resolutions return the same instance. Mirrors health.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 fetchFn was 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 fetchFn called 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) — fetchFn returns 429 then 200: the client retries and returns the success; 429 on every attempt within the bound throws Gw2RateLimitError.
  • F4 coverage — a 206 fixture with a missing id returns the present ids and omits the absent one; a 404 chunk contributes nothing without throwing.
  • SC9 (offline) — the whole suite runs with no network access; guaranteed structurally by injecting fetchFn in 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 control setTimeout/Date.now globally, 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 400 at 200; 199 is the real cap.

Risks ​

  • Async token bucket ↔ fake timers. Promises resolved via setTimeout need advanceTimersByTimeAsync (not the sync variant) or tests hang. Mitigation: the bucket resolves strictly through setTimeout; 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.
  • 206 mis-handled as an error. A naïve res.ok check treats 206 as ok (it is), but a naïve status === 200 check would not. Mitigation: an explicit status === 200 || status === 206 success test, with a 206 fixture 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.