Skip to content

Tasks 005 — GW2 API v2 client ​

Execution skill: superpowers:subagent-driven-development — one implementer per task, then a two-stage review (spec compliance, then code quality). superpowers:test-driven-development applies inside every task: no production code before a failing test that demands it. Reach for superpowers:systematic-debugging on any surprise rather than guessing.

Derived from plan.md (approved). Each task is small, independently verifiable, and reviewed as its own diff. Split where a reviewer could reject one task while approving its neighbour — not where the work merely changes subject. A task is done only when it satisfies the definition of done in CLAUDE.md.

Global Constraints in plan.md apply to every task and are not repeated per task.

Build order is bottom-up: the four leaf units first (T1–T3), then the client that composes them (T4–T6), then the Nest wrapper that exposes it (T7), then close-out (T8). Every test is offline — Gw2Client always receives a fake fetchFn; a real fetch never runs in the suite (SC9).


T1 — TokenBucket gates requests to 300 burst / 5-per-second ​

Satisfies: R3, SC2, P1 #2.

Files: create apps/api/src/gw2/token-bucket.ts, apps/api/src/gw2/token-bucket.test.ts.

  • [ ] RED: SC2/P1#2: 300 take() calls resolve immediately, the 301st waits for refill. With vi.useFakeTimers(), construct new TokenBucket({ capacity: 300, refillPerSec: 5 }), fire 305 take() calls; assert exactly 300 promises are resolved at t=0 and that the next resolves only after advanceTimersByTimeAsync(200) (one token every 200 ms), never exceeding the budget in any 1 s window. Watch it fail (TokenBucket is not defined) for the right reason.
  • [ ] GREEN: smallest TokenBucket — a float token count, last-refill timestamp, take() returning a promise resolved now if a token is free, else scheduled via setTimeout at the refill interval.
  • [ ] REFACTOR: only with the test green.
  • [ ] Teeth: set capacity to Infinity in the impl; the 301st-waits assertion must fail. Restore.
  • [ ] Commit: api: add TokenBucket for GW2 rate budget (R3, SC2).

Verified by: token-bucket.test.ts › SC2/P1#2: ….


T2 — BoundedCache<V> expires by TTL and evicts oldest at the cap ​

Satisfies: R6, SC5 (cache mechanism), SC6.

Files: create apps/api/src/gw2/bounded-cache.ts, apps/api/src/gw2/bounded-cache.test.ts.

  • [ ] RED: three tests under fake timers — SC5: an entry with a 60_000 ms TTL is a miss after 60 s (set, advanceTimersByTimeAsync(60_001), expect get undefined); R6: a no-TTL entry never expires; SC6: setting past the size cap evicts the oldest key (cap=2, insert 3 keys, assert size === 2 and the first key is gone). Watch them fail.
  • [ ] GREEN: BoundedCache<V> over a Map, set(key, value, ttlMs?) storing an expiry; get deletes and misses on expiry; on set beyond maxEntries, delete the oldest (Map insertion order — this.store.keys().next().value). Expose size.
  • [ ] REFACTOR: only with the test green.
  • [ ] Teeth: disable the eviction branch; SC6 must fail. Restore.
  • [ ] Commit: api: add BoundedCache with TTL + size cap (R6, SC6).

Verified by: bounded-cache.test.ts › SC5: …, SC6: ….


T3 — Zod schemas, typed errors, and captured fixtures ​

Satisfies: R7, SC7, P1 #6.

Files: create apps/api/src/gw2/gw2.schemas.ts, apps/api/src/gw2/gw2.errors.ts, apps/api/src/gw2/gw2.schemas.test.ts, and apps/api/src/gw2/__fixtures__/{items,recipes,recipe-search,prices}.json.

  • [ ] Capture fixtures (setup for this task, not throwaway): save real responses — items.json from /v2/items?ids=19721,19685, recipes.json from /v2/recipes?ids=1,2, recipe-search.json from /v2/recipes/search?output=46742, prices.json from /v2/commerce/prices?ids=19721,19685. Shapes are pinned in plan.md › Data & contracts.
  • [ ] RED: in gw2.schemas.test.ts — SC7/P1#6: each fixture parses to the depended-on fields (item/recipe/search/price against their schema); SC7: a fixture with a required field deleted throws Gw2ValidationError; SC7: a fixture with an extra unknown field parses and the extra is absent from the result. Watch them fail (schemas/errors undefined).
  • [ ] GREEN: define Gw2ValidationError, Gw2RateLimitError, Gw2RequestError in gw2.errors.ts (each extends Error, sets name). In gw2.schemas.ts define Gw2ItemSchema, Gw2RecipeSchema, Gw2RecipeSearchSchema (z.array(z.number())), Gw2PriceSchema with only the plan's fields, plus a parseOrThrow(schema, data) helper that wraps schema.parse and rethrows Zod failures as Gw2ValidationError. Export inferred types Gw2Item, Gw2Recipe, Gw2Price.
  • [ ] REFACTOR: only with the test green.
  • [ ] Teeth: make a schema field z.unknown(); the mistyped-field test must stop throwing. Restore.
  • [ ] Commit: api: add GW2 Zod schemas, typed errors, fixtures (R7, SC7).

Verified by: gw2.schemas.test.ts › SC7/P1#6: ….


T4 — Gw2Client reads: batch ≤199, budget, validate, reassemble ​

Satisfies: R2, R5, SC3, SC9, P1 #3, and F4 (206/404) from research.md.

Files: create apps/api/src/gw2/gw2-client.ts, apps/api/src/gw2/gw2-client.test.ts.

  • [ ] RED: with an injected fetchFn (a vi.fn() returning Response-like objects from fixtures) — SC3/P1#3: items() of 250 ids issues ⌈250/199⌉ = 2 fetches (assert fetchFn.mock.calls.length and that each URL's ids= holds ≤199); F4: a 206 response omits a missing id from the result, no throw; F4: a 404 chunk contributes nothing, no throw; SC9: no test supplies a real fetch. Watch them fail.
  • [ ] GREEN: Gw2Client constructor { fetchFn = globalThis.fetch, baseUrl = 'https://api.guildwars2.com/v2', bucket }. A private getByIds(path, schema, ids) that chunks to ≤199, await bucket.take() before each fetch, treats 200/206 as success and 404 as empty, calls parseOrThrow on the body, and reassembles in requested order (omitting absent ids). Public items, recipes, prices delegate to it; searchRecipes({ input?, output? }) fetches the single query URL and parses Gw2RecipeSearchSchema. Non-ok, non-404, non-429 → Gw2RequestError.
  • [ ] REFACTOR: only with the test green.
  • [ ] Teeth: raise the chunk size to 500; the SC3 two-fetch assertion must fail. Restore to 199.
  • [ ] Commit: api: add Gw2Client batched reads with validation (R2, R5, SC3).

Verified by: gw2-client.test.ts › SC3/P1#3: …, F4: ….


T5 — Gw2Client caching: static hard, prices 60 s ​

Satisfies: R6, SC4, SC5, P1 #4, P1 #5.

Files: modify apps/api/src/gw2/gw2-client.ts; add tests to apps/api/src/gw2/gw2-client.test.ts.

  • [ ] RED: SC4/P1#4: items() for the same id twice issues one fetch (second call, assert fetchFn called once); SC5/P1#5: prices() for an id twice within 60 s issues one fetch, and a third call after advancing past 60 s issues a second fetch (fake timers). Watch them fail (cache not yet wired — currently two fetches).
  • [ ] GREEN: give Gw2Client a BoundedCache per data class (static, price). In getByIds, partition requested ids into cache hits and misses, fetch only misses, then cache each returned id — static with no TTL, prices with 60_000 ms. searchRecipes caches by its query key with no TTL.
  • [ ] REFACTOR: only with the test green.
  • [ ] Teeth: give the price cache a no-TTL store; the SC5 after-60 s second-fetch assertion must fail. Restore the 60 s TTL.
  • [ ] Commit: api: cache GW2 reads — static hard, prices 60s (R6, SC4, SC5).

Verified by: gw2-client.test.ts › SC4/P1#4: …, SC5/P1#5: ….


T6 — Gw2Client 429 handling: bounded backoff, opportunistic Retry-After ​

Satisfies: R4, SC8, P1 #7.

Files: modify apps/api/src/gw2/gw2-client.ts; add tests to apps/api/src/gw2/gw2-client.test.ts.

  • [ ] RED: under fake timers — SC8/P1#7: a 429 then a 200 retries and returns the success (assert two fetches, resolved value from the 200); SC8: 429 on every attempt within the bound throws Gw2RateLimitError; R4: a 429 carrying Retry-After: 1 waits ~1 s before retrying (assert the retry fires only after advanceTimersByTimeAsync(1000)). Watch them fail.
  • [ ] GREEN: wrap the per-chunk fetch in a bounded retry (max 4 attempts): on 429, wait Retry-After seconds if the header is present, else base 250 ms exponential backoff with jitter, via setTimeout; exhausting attempts throws Gw2RateLimitError. Backoff is primary; Retry-After only honored opportunistically (research V2).
  • [ ] REFACTOR: only with the test green.
  • [ ] Teeth: set max attempts to 1; the 429-then-200 success test must fail. Restore to 4.
  • [ ] Commit: api: retry GW2 429s with bounded backoff (R4, SC8).

Verified by: gw2-client.test.ts › SC8/P1#7: …, R4: ….


T7 — Gw2Service + Gw2Module, wired into the app as a singleton ​

Satisfies: R1, R10, SC1, P1 #1.

Files: create apps/api/src/gw2/gw2.service.ts, apps/api/src/gw2/gw2.module.ts, apps/api/src/gw2/gw2.service.test.ts; modify apps/api/src/app.module.ts.

  • [ ] RED: gw2.service.test.ts — SC1/P1#1: Gw2Service resolves from a compiled Nest module and is a singleton. Build via Test.createTestingModule({ imports: [Gw2Module] }).compile() (mirroring health.controller.test.ts); assert get(Gw2Service) is defined and two get calls return the same instance. Watch it fail.
  • [ ] GREEN: Gw2Service — @Injectable() constructing one Gw2Client (default fetch, a TokenBucket at 300/5) and delegating items/recipes/searchRecipes/prices. Gw2Module provides and exports it. Add Gw2Module to AppModule.imports (one line).
  • [ ] REFACTOR: only with the test green.
  • [ ] Teeth: temporarily scope the provider transient; the singleton-identity assertion must fail. Restore default (singleton) scope.
  • [ ] Confirm R10: git status shows no change under apps/web, and apps/api/openapi.json is untouched (no route was added).
  • [ ] Commit: api: expose Gw2Service via Gw2Module, wire into AppModule (R1, SC1).

Verified by: gw2.service.test.ts › SC1/P1#1: …; git status clean of web/contract files.


T8 — Close-out: traceability + full verification ​

Satisfies: the definition of done in CLAUDE.md (every criterion traced to a named test).

Files: modify specs/005-gw2-api-client/spec.md (traceability table only).

  • [ ] Fill spec.md's traceability table — one row per P1 #n and SC mapping to the exact test name from T1–T7 (a transcription, since each test was named for its criterion).
  • [ ] Run the full gate from the repo root and paste the passing output into the review: pnpm typecheck && pnpm test && pnpm lint && pnpm build. All green, no any, no escape hatches.
  • [ ] Note in ## Notes anything that turned out differently from plan.md, for step 6 graduation.
  • [ ] Commit: specs: 005 fill traceability table for GW2 API client.

Verified by: pnpm typecheck && pnpm test && pnpm lint && pnpm build green from the root; every row of the traceability table names a passing test.


Notes ​

Staging area for decisions and surprises found during implementation — including anything that turned out differently from what plan.md assumed. Move each one into spec.md, research.md, or docs/ before closing the feature; this section is not a home.

  • Graduation candidates already known (from research.md): the effective 199-id cap, 206/404 partial semantics, the 600-header trap, Retry-After being undocumented → a docs/architecture/gw2-api.md at step 6.
  • Implementation finding (T3): in Zod v4, a required key must be present regardless of its value schema — a bare z.unknown() field still fails parsing when the key is absent; .optional() is what disables the presence check. Worth a line in docs/architecture/typescript.md if it bites again.
  • Design note: caching is per-id (items/recipes/prices) and per-query (searchRecipes); a 429 never poisons the cache (it throws before any write), and absent ids are omitted, never negatively cached. searchRecipes rejects a query that isn't exactly one of input/output.
  • All 8 tasks landed clean through per-task spec+quality review and a final whole-branch review (opus); only one should-fix (a tautological SC9 test) surfaced and was corrected. Full gate green: typecheck + 126 tests + lint (gw2 code warning-free) + build.