Skip to content

Research 005 — GW2 API v2 client ​

Status: complete

Step 1.5 output, written between the spec draft and the approval gate. Every [NEEDS VERIFICATION] marker in spec.md (R3, R4, R5, R7) has a verdict below, plus findings that turned up alongside.

Verified against. The live GW2 API v2 (https://api.guildwars2.com/v2) and the official wiki (API:Best_practices, API:2), all on 2026-07-27. GW2's static data drifts with game patches and the rate-limit policy could change server-side; a later reader should re-run the live probes below to confirm these still hold.

V1 — Is the rate limit a 300-burst / 5-per-second token bucket returning 429? (spec R3, SC2) ​

Question. R3/SC2 size the client's proactive token bucket at 300 burst, refill 5/sec, with 429 on overflow. The whole budgeting design rests on these numbers.

Verdict. Confirmed. Implement a per-process token bucket of capacity 300, refill 5 tokens/sec; expect 429 only on overflow. The spec numbers stand.

Evidence. GW2 Wiki, API:Best_practices → Rate Limit (fetched 2026-07-27): "token bucket … 300 burst … 5 tokens per second (300 per minute) … HTTP 429 on overflow … applied per IP address … With 300 burst and a refill of 5 tokens per second, you will get a reliable stream of requests with no 429s." API:2 corroborates the 429-on-overflow behavior.

Caveat. A live response carries x-rate-limit-limit: 600 (observed on GET /v2/items?ids=19721, 2026-07-27) — this advertises 600 and does not match the documented 300-burst bucket. See F6. Do not derive the bucket size from that header; hard-code the documented 300 / 5-per-sec. A live 429 was deliberately not force-triggered (it would require bursting 300+ requests at the shared IP — abusive for a one-header confirmation); the numbers are taken from the authoritative doc, not a forced overflow.

V2 — Does the GW2 API return a Retry-After header on 429? (spec R4, SC8) ​

Question. R4 says the client honors Retry-After "when present." The verification question is whether GW2 actually sends it.

Verdict. Refuted (as a guarantee). Retry-After is not documented and must not be relied on. The client's real recovery mechanism must be its own bounded exponential backoff; Retry-After may be honored opportunistically if it happens to appear, but the design cannot depend on it.

Evidence. GW2 Wiki API:Best_practices Rate Limit section (fetched 2026-07-27) describes the 429 behavior but makes no mention of a Retry-After header or any x-rate-limit-* header governing retry timing. Live 200 responses carry no Retry-After (headers dumped from GET /v2/items?ids=19721, 2026-07-27). A live 429 was not forced (see V1 caveat), so absence on the 429 path specifically is inferred from the documentation's silence, not observed.

Caveat. This is "confirmed absent from the docs," not "observed absent on a live 429." The design is robust either way because backoff — not the header — is the primary mechanism.

V3 — Do items/recipes/prices accept a batched ?ids=, and is the cap really 200? (spec R5, SC3) ​

Question. R5/SC3 assume every multi-id read chunks to ≤200 ids per ?ids= request, and specifically that /v2/commerce/prices accepts batched ?ids= the same way /v2/items does.

Verdict. Confirmed, with one correction: the effective cap is 199, not 200. All three endpoints (/v2/items, /v2/recipes, /v2/commerce/prices) accept batched ?ids=. But a request of 200 distinct ids is rejected with HTTP 400 "id list too long; this endpoint is limited to 200 ids at once" — the server's own message is off by one. The client must chunk to ≤199 ids per request. This changes R5 and SC3 (see Refuted claims).

Evidence. Live boundary probe against /v2/items, 2026-07-27:

  • ?ids= 199 distinct ids → HTTP 206 (partial content, valid — see F4).
  • ?ids= 200 distinct ids → HTTP 400 {"text":"id list too long; this endpoint is limited to 200 ids at once"}.
  • ?ids= 201 distinct ids → HTTP 400 (same message).

Batched ?ids= confirmed returning arrays on all three endpoints:

  • /v2/items?ids=19721,19685 → 2-element array.
  • /v2/recipes?ids=1,2 → 2-element array.
  • /v2/commerce/prices?ids=19721,19685 → 2-element array (so prices batches — the specific worry in R5).

V4 — What are the response shapes the client must validate? (spec R7) ​

Question. R7 validates "only the fields the client depends on." That requires knowing each endpoint's actual shape.

Verdict. Confirmed. Live shapes captured 2026-07-27 (these become the fixtures and the Zod schemas):

  • /v2/items — object with id (number), name (string), type (string), rarity (string), flags (string array), vendor_value (number), plus fields not needed yet (description, level, game_types, restrictions, chat_link, icon). flags[] is present and drives later gated-input classification.
  • /v2/recipes — id, type, output_item_id, output_item_count, disciplines (string array), min_rating, flags, and ingredients (array of { item_id, count }), plus time_to_craft_ms, chat_link, guild_ingredients.
  • /v2/recipes/search?output=<id> (and ?input=<id>) — a bare array of recipe id numbers (e.g. [12053, 7319]), not objects.
  • /v2/commerce/prices — id, whitelisted (bool), buys { quantity, unit_price }, sells { quantity, unit_price }. unit_price is in copper.

Evidence. Raw responses for ?ids=19721,19685 (items, prices), ?ids=1,2 (recipes), ?output=46742 (search), captured live 2026-07-27.

F4 — Partial / invalid id lists return 206, not an error (touches R5, R7, SC3) ​

Why it matters. A batched ?ids= where some ids don't exist returns HTTP 206 Partial Content with only the valid ids in the array — invalid ids are silently omitted, not errored. (Observed: 199 low ids → 206 with a subset.) Only when every id is invalid does the API return 404. So the client must treat both 200 and 206 as success, and SC3's "the reassembled result covers every requested id" holds only for ids that exist — a caller asking for a non-existent id gets it omitted, not an error. R7's validator must not choke on a short array.

F5 — The API advertises its own cache lifetime (supports R6) ​

Why it matters. /v2/items responses carry cache-control: public, max-age=3600 (observed 2026-07-27). Upstream itself treats item data as cacheable for an hour, which corroborates R6's decision to hard-cache static data. It does not dictate our TTLs (R6 keeps static immortal / prices 60s), but it's supporting evidence that static data is safe to cache aggressively.

F6 — The x-rate-limit-limit: 600 header contradicts the 300-burst doc (touches R3) ​

Why it matters. The live header says 600 while the best-practices doc says the burst bucket is 300. The two disagree; the header looks like a legacy/advisory value. R3's implementation must follow the documented 300 / 5-per-sec bucket and ignore this header, or it will size the budget at double the real capacity and eat 429s.

Refuted claims ​

Recorded rather than silently patched, per the workflow. The spec is still draft, so these are folded into the draft before it goes for approval — the discovery evidence, not enthusiasm, decided the change.

  1. "200 ids per ?ids= request" (R5, SC3). Believed: the cap is 200. True: the server rejects at 200 (HTTP 400); the effective maximum is 199. Change: R5 and SC3 reworded to chunk at ≤199 and to divide by 199, with the off-by-one recorded so no later reader "corrects" it back to 200. This is a boundary refinement, not a collapse of the premise — batched reads still work exactly as the spec intends.
  2. "GW2 returns Retry-After on 429" (implied by R4). Believed: the header is available to honor. True: it is undocumented and not guaranteed. Change: R4 reworded so bounded exponential backoff is the primary mechanism and Retry-After is honored only opportunistically.

Graduation ​

Candidates to move to docs/architecture/ at step 6 (they outlive this feature and the next spec should not re-derive them). stack.md already states the rate-limit/caching posture; these are refinements to graduate alongside it, likely as a docs/architecture/gw2-api.md:

  • The effective 199-id batch cap (documented as 200, enforced at 199).
  • 206 partial-content semantics for partially-valid id lists; 404 only when all ids are invalid.
  • The 300 / 5-per-sec bucket is authoritative; the x-rate-limit-limit: 600 header is not to be trusted.
  • Retry-After is undocumented — backoff is the mechanism.