Spec 005 — GW2 API v2 client
Status: implemented Branch: 005-gw2-api-client
Status is set by the human, never by the agent. It moves draft → approved → implemented.
Problem
docs/architecture/stack.md commits the project to one hard rule — "all GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service" — but that client does not exist. Specs 001–004 built the workbench and stood up two walking-skeleton apps; nothing yet reaches outside the process to Guild Wars 2's servers. Every product feature on the roadmap (dependency-tree expansion, live pricing, buy-vs-craft) depends on reading GW2 API v2 data under a real rate limit — a per-IP token bucket (300 burst, refill 5/sec, 429 on overflow) with a 200-id cap per batched call. Without a single budgeted chokepoint, each future service would re-implement throttling, batching, and caching, and any one of them could exhaust the shared budget for all the others.
This spec builds that chokepoint: a typed, rate-budgeted, cached GW2 API v2 client inside apps/api, covering the unauthenticated read surface the planner needs. It deliberately builds no product feature — no dependency tree, no pricing model, no account access, no HTTP route. It is the frame the first real feature hangs on.
User stories
Ordered by priority. Each story must be independently testable and shippable — if only P1 ships, there is still something usable.
P1 — One budgeted client for GW2 read data
As the developer building later features, I want a single injectable client that fetches validated GW2 items, recipes, and prices — batching, caching, and staying under the rate limit on my behalf — so that every service reads GW2 data the same safe way and no service can exhaust the shared budget.
Independent test: with the client wired into the api's DI container and no other story implemented, a caller resolves it, requests items / recipes / recipe-search / prices, and receives validated typed results; a burst of requests never exceeds the rate budget; repeated static reads hit cache; malformed responses throw. All verified offline against fixtures with fake timers.
Acceptance scenarios
- Given the client is registered in the api's DI container, when the api boots, then the client resolves as a single shared instance (one rate budget for the whole process), exactly as
HealthServiceresolves today. - Given the token bucket is full, when more requests are enqueued in a burst than the bucket holds, then the client dispatches only as many as the budget allows immediately and releases the remainder at the refill rate, never issuing a request the budget does not cover.
- Given a request for more ids than the per-call batch cap, when the caller asks for them in one call, then the client splits them into the minimum number of
?ids=requests within the cap and returns the reassembled results. - Given an item or recipe already fetched, when the same id is requested again, then the client returns it from cache and issues zero network requests.
- Given a price fetched for an id, when it is requested again before its TTL elapses, then the client serves it from cache (0 network requests); when requested again after the TTL elapses, then the client re-fetches it (1 network request).
- Given a GW2 response whose shape does not match what the client depends on, when it is received, then the client throws a typed validation error rather than returning unshaped data; given a response carrying extra unknown fields, then the client returns successfully, tolerating them.
- Given the server responds
429, when the client receives it, then it waits and retries within a bounded number of attempts; when the attempts are exhausted, then it throws a typed rate-limit error.
Requirements
- R1 — The client lives in
apps/api/src/gw2/as a framework-agnostic core class wrapped by a NestJS@Injectable()provider registered as a singleton (one instance per process). - R2 — It exposes typed reads for exactly these unauthenticated GW2 API v2 endpoints and no others:
/v2/items,/v2/recipes,/v2/recipes/search(byinput/outputitem id),/v2/commerce/prices. - R3 — A single process-wide token bucket gates every outbound request so the client structurally cannot exceed the GW2 rate budget of 300 burst, refill 5/sec (confirmed,
research.mdV1). The bucket size is hard-coded from that documented figure; the misleadingx-rate-limit-limit: 600response header is ignored (research.mdF6). - R4 — On
429, the client retries within a bounded number of attempts using bounded exponential backoff (cap + jitter) as the primary mechanism; aRetry-Afterheader is honored only opportunistically if present, never depended on, because GW2 does not document it (research.mdV2). Exhausting the attempts throws a typed rate-limit error. - R5 — Multi-id reads are automatically chunked to at most 199 ids per
?ids=request and the results reassembled for the caller (confirmed,research.mdV3 — the server rejects at 200 despite documenting a 200 cap). A partial result (HTTP 206, some ids non-existent) is treated as success: missing ids are omitted from the reassembled result, not errored (research.mdF4). - R6 — Responses are cached in-memory in a bounded store (a size cap; no unbounded
Map). Static data (items,recipes, recipe-search results) is cached with no expiry; prices are cached with a 60-second TTL. A cache hit issues zero network requests. No cache invalidation, persistence, or stampede protection is built. - R7 — Every response is parsed through a Zod schema before being returned. The client validates only the fields it depends on and tolerates unknown extra fields (shapes confirmed live in
research.mdV4). A response that fails validation throws a typed error; unshaped data is never returned to a caller. - R8 — The client is verified entirely offline: tests run against committed fixtures (real GW2 JSON captured once) with a mocked fetch, and the token bucket's refill and the cache TTLs are driven by Vitest fake timers. CI issues no network request.
- R9 — API keys and authenticated account endpoints are not part of this client; it reads only unauthenticated data.
- R10 — No new HTTP route is added and
apps/webis unchanged; the api's committedopenapi.jsonand the Orval-generated web client are untouched. The client is internal toapps/api.
Mark anything unresolved inline rather than assuming an answer. Two markers, split by who can answer:
[NEEDS CLARIFICATION: specific question]— only the human can answer. A product decision, a scope boundary, a preference. Blocks step 1.5.[NEEDS VERIFICATION: specific question]— only reality can answer. Whether the codebase works that way, whether an endpoint returns that field, whether that number is achievable. Answered inresearch.mdwith cited evidence, never by assumption. Blocks the approval gate.
Any success criterion stating a number carries a [NEEDS VERIFICATION] until a measurement in research.md backs it. An unbacked number is a guess wearing a criterion's clothes.
Success criteria
Measurable and technology-agnostic — outcomes, not implementation.
- SC1 — The client resolves from the api's DI container as a single shared instance; the api boots with it wired, with no regression to the existing boot/health path.
- SC2 — Under a burst larger than the bucket capacity, the number of requests dispatched in any one-second window never exceeds the budget, and the overflow is released at the refill rate — asserted deterministically with fake timers. (Budget confirmed 300 / 5-per-sec,
research.mdV1.) - SC3 — A multi-id read of N ids issues exactly ⌈N / 199⌉ network requests, and the reassembled result covers every requested id that exists (non-existent ids are omitted, not errored). (Cap confirmed 199,
research.mdV3–F4.) - SC4 — Two identical static reads (same item or recipe id) issue exactly one network request; the second is served from cache.
- SC5 — A price read repeated before the 60-second TTL issues one network request; repeated after the TTL elapses issues two — verified with fake timers.
- SC6 — The in-memory store never exceeds its configured size cap under sustained distinct reads (no unbounded growth).
- SC7 — A response missing or mis-typing a depended-on field causes a typed validation error to be thrown; a response with extra unknown fields returns successfully.
- SC8 — A
429response is retried within the bounded attempt limit and, if never satisfied, surfaces a typed rate-limit error; a429followed by success returns the successful result. (Backoff is the mechanism;Retry-Afteris not relied on —research.mdV2.) - SC9 — The entire test suite passes with no network access available.
Out of scope
- Authenticated / account endpoints, API-key handling, encryption at rest — the "Account state" slice.
- Any dependency-tree expansion, Mystic Forge dataset, cost/profit computation, or buy-vs-craft logic.
/v2/commerce/listings(order-book depth) and any endpoint outside R2.- Any new HTTP route, response DTO,
openapi.jsonchange, orapps/webchange. - Redis or any out-of-process cache; cache invalidation, persistence, or stampede protection.
- A live smoke test against the real GW2 API (deliberately deferred; may be revisited in research).
- Request coalescing / de-duplication of concurrent identical in-flight requests.
Assumptions
- The GW2 API v2 endpoints in R2 are reachable and unauthenticated (no key required for reads).
- The rate-limit and batch-cap figures are confirmed against the live API in
research.md(300 / 5-per-sec bucket; effective 199-id cap). These findings are dated 2026-07-27 and could drift if GW2 changes policy. apps/api's existing SWC build, Vitest config, and tsconfig coversrc/gw2/with no new workspace wiring; Zod is already available in the api.- In-memory caching is acceptable for the MVP (per
stack.md); a single api process holds one budget.
Traceability
Each acceptance scenario and success criterion maps to a named test. All tests live under apps/api/src/gw2/.
| Criterion | Test |
|---|---|
| P1 #1 | gw2.service.test.ts › T7 › SC1/P1#1: Gw2Service resolves from a compiled Nest module and is a singleton |
| P1 #2 | token-bucket.test.ts › T1 › SC2/P1#2: 300 take() calls resolve immediately, the 301st waits for refill |
| P1 #3 | gw2-client.test.ts › T4 › SC3/P1#3: items() of 250 ids issues ⌈250/199⌉ = 2 fetches, each chunk ≤199 ids |
| P1 #4 | gw2-client.test.ts › T5 › SC4/P1#4: items() for the same id twice issues one fetch |
| P1 #5 | gw2-client.test.ts › T5 › SC5/P1#5: prices() … one fetch within 60 s; a second after advancing past 60 s |
| P1 #6 | gw2.schemas.test.ts › T3 › SC7/P1#6: each fixture parses to the depended-on fields |
| P1 #7 | gw2-client.test.ts › T6 › SC8/P1#7: a 429 then a 200 retries and returns the success |
| SC1 | gw2.service.test.ts › T7 › SC1/P1#1: Gw2Service resolves from a compiled Nest module and is a singleton |
| SC2 | token-bucket.test.ts › T1 › SC2/P1#2: 300 take() calls resolve immediately, the 301st waits for refill |
| SC3 | gw2-client.test.ts › T4 › SC3/P1#3: items() of 250 ids issues ⌈250/199⌉ = 2 fetches, each chunk ≤199 ids (+ F4: 206 … omits a missing id, F4: a 404 chunk contributes nothing) |
| SC4 | gw2-client.test.ts › T5 › SC4/P1#4: items() for the same id twice issues one fetch |
| SC5 | gw2-client.test.ts › T5 › SC5/P1#5: prices() … one fetch within 60 s; a second after 60 s; mechanism: bounded-cache.test.ts › T2 › SC5: an entry with a 60_000 ms TTL is a miss after 60 s |
| SC6 | bounded-cache.test.ts › T2 › SC6: setting past the size cap evicts the oldest key |
| SC7 | gw2.schemas.test.ts › T3 › SC7/P1#6: each fixture parses …, SC7: a required field deleted throws Gw2ValidationError, SC7: an extra unknown field parses and the extra is absent |
| SC8 | gw2-client.test.ts › T6 › SC8/P1#7: a 429 then a 200 retries and returns the success, SC8: 429 on every attempt within the bound throws Gw2RateLimitError, R4: a 429 carrying Retry-After: 1 waits ~1 s before retrying |
| SC9 | gw2-client.test.ts › T4 › SC9: a client with an injected fetchFn never touches globalThis.fetch |