From fa6977e66d1f62a0d72ebad53dc8ff131586e7cc Mon Sep 17 00:00:00 2001 From: Ryan Wong Date: Mon, 10 Aug 2026 21:33:12 +0100 Subject: [PATCH 1/2] fix(raiderio): treat a private user profile as no profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raider.IO answers 403 profile_is_private when a guessed user name belongs to someone whose profile is private. Every non-404 failure was classified transient, so this permanent answer was retried five times and then failed the run as upstream_unavailable — "Character data is temporarily unavailable." for any character whose ownership is not public and whose name matches a private Raider.IO user. Classify 403 as a distinct forbidden failure and treat it like not_found when resolving a profile guess: an invisible profile yields no relationships, which is not an outage. Character and claimed-character requests keep their present behaviour for a 403. Confirmed against the live endpoint, which returns 403 for /api/user/view-characters?name=shurkle. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qh8Zb2HnaxebWrRLUMoAiv --- packages/raiderio/src/client.test.ts | 11 +++++++++++ packages/raiderio/src/client.ts | 11 ++++++++++- packages/raiderio/src/errors.ts | 2 ++ tests/fixtures/raiderio/profile-forbidden.json | 9 +++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/raiderio/profile-forbidden.json diff --git a/packages/raiderio/src/client.test.ts b/packages/raiderio/src/client.test.ts index 29b96f2..2188e20 100644 --- a/packages/raiderio/src/client.test.ts +++ b/packages/raiderio/src/client.test.ts @@ -16,6 +16,7 @@ type FixtureName = | "character-renamed-root" | "profile-valid" | "profile-invalid" + | "profile-forbidden" | "claimed-characters" | "claimed-characters-out-of-scope" | "missing-character" @@ -45,6 +46,7 @@ function fixtureFetch(name: FixtureName): typeof globalThis.fetch { const expectsProfile = name === "profile-valid" || name === "profile-invalid" || + name === "profile-forbidden" || name === "claimed-characters" || name === "claimed-characters-out-of-scope"; const expectedPath = expectsProfile @@ -289,6 +291,15 @@ describe("Raider.IO gateway", () => { ).resolves.toBeNull(); }); + it("treats a private user profile as no profile, not an outage", async () => { + // Break caught: Raider.IO answers 403 profile_is_private for a guessed user + // name. Classifying that as transient retried a permanent answer and failed + // the whole run as upstream_unavailable. + await expect( + clientFor("profile-forbidden").resolveProfileGuess("private-user") + ).resolves.toBeNull(); + }); + it("classifies a missing character", async () => { await expect( clientFor("missing-character").getCharacter(sentinel) diff --git a/packages/raiderio/src/client.ts b/packages/raiderio/src/client.ts index a16628e..fdf08a5 100644 --- a/packages/raiderio/src/client.ts +++ b/packages/raiderio/src/client.ts @@ -35,6 +35,10 @@ function retryAfterMs(response: Response): number | undefined { function responseFailure(response: Response): RaiderIoFailure { if (response.status === 404) return { kind: "not_found" }; + // Raider.IO answers 403 for a user profile its owner has made private. That + // is a permanent answer about visibility, not an outage, so it must never be + // retried as one. + if (response.status === 403) return { kind: "forbidden" }; const retryAfter = retryAfterMs(response); return { @@ -172,7 +176,12 @@ export function createRaiderIoClient( ...(profile.omittedMembers ? { omittedMembers: true } : {}) }; } catch (error) { - if (isRaiderIoFailure(error) && error.kind === "not_found") return null; + if ( + isRaiderIoFailure(error) && + (error.kind === "not_found" || error.kind === "forbidden") + ) { + return null; + } throw error; } } diff --git a/packages/raiderio/src/errors.ts b/packages/raiderio/src/errors.ts index c8aea6e..279c7f5 100644 --- a/packages/raiderio/src/errors.ts +++ b/packages/raiderio/src/errors.ts @@ -1,5 +1,6 @@ export type RaiderIoFailure = | { kind: "not_found" } + | { kind: "forbidden" } | { kind: "transient"; status?: number; @@ -14,6 +15,7 @@ export function isRaiderIoFailure(value: unknown): value is RaiderIoError { return ( value.kind === "not_found" || + value.kind === "forbidden" || value.kind === "transient" || value.kind === "schema_drift" ); diff --git a/tests/fixtures/raiderio/profile-forbidden.json b/tests/fixtures/raiderio/profile-forbidden.json new file mode 100644 index 0000000..f98b944 --- /dev/null +++ b/tests/fixtures/raiderio/profile-forbidden.json @@ -0,0 +1,9 @@ +{ + "status": 403, + "body": { + "statusCode": 403, + "error": "Forbidden", + "message": "The requested user's profile is private and cannot be viewed.", + "errorCode": "profile_is_private" + } +} From 7f22c13dd6f0f68f65fbcc3315c1e9724f9663d3 Mon Sep 17 00:00:00 2001 From: Ryan Wong Date: Mon, 10 Aug 2026 21:59:58 +0100 Subject: [PATCH 2/2] fix(blizzard): resolve roster class names and skip unusable members Blizzard's guild roster carries playable_class as {id, key} with no name. The normalizer required a name, so every member normalized to null, members.every() rejected the roster, and the sweep died with non-retryable schema drift. Verified against the live endpoint: 393 of 393 members in the measured guild lack playable_class.name. The unit fixture had invented a name, so the suite passed on a shape Blizzard never sends. No guilded root could ever have been swept. Resolve names once per process from the static playable-class index, accounted as one request per sweep, and keep accepting an inline name if Blizzard ever sends one. Also skip a member the key space cannot represent rather than failing the roster, the same treatment Raider.IO claimed characters already get: one unusable member should not abandon a whole sweep. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qh8Zb2HnaxebWrRLUMoAiv --- packages/blizzard/src/client.test.ts | 55 +++++++++++++++++++++- packages/blizzard/src/client.ts | 70 ++++++++++++++++++++++++---- 2 files changed, 115 insertions(+), 10 deletions(-) diff --git a/packages/blizzard/src/client.test.ts b/packages/blizzard/src/client.test.ts index 69cdec2..510bd81 100644 --- a/packages/blizzard/src/client.test.ts +++ b/packages/blizzard/src/client.test.ts @@ -75,6 +75,14 @@ describe("Blizzard gateway", () => { guild: { name: "A Guild", realm: { slug: "silvermoon" } } }); } + if (url.pathname === "/data/wow/playable-class/index") { + return Response.json({ + classes: [ + { id: 8, name: "Mage" }, + { id: 2, name: "Paladin" } + ] + }); + } if (url.pathname.endsWith("/guild/silvermoon/a-guild/roster")) { return Response.json({ members: [ @@ -82,7 +90,7 @@ describe("Blizzard gateway", () => { character: { name: "Alt", realm: { slug: "Silvermoon" }, - playable_class: { name: "Mage" }, + playable_class: { id: 8 }, level: 80 } } @@ -103,7 +111,50 @@ describe("Blizzard gateway", () => { level: 80 } ]); - expect(onProfileRequest).toHaveBeenCalledTimes(2); + expect(onProfileRequest).toHaveBeenCalledTimes(3); + }); + + it("skips an unusable roster member instead of failing the sweep", async () => { + // Break caught: one member the key space cannot represent made every member + // null, which raised schema_drift and abandoned the whole sweep. + const { gateway } = clientFor((url) => { + if (url.hostname === "oauth.battle.net") return tokenResponse(); + if (url.pathname === "/data/wow/playable-class/index") { + return Response.json({ classes: [{ id: 8, name: "Mage" }] }); + } + if (url.pathname.endsWith("/character/silvermoon/sentinel")) { + return Response.json({ + guild: { name: "A Guild", realm: { slug: "silvermoon" } } + }); + } + if (url.pathname.endsWith("/guild/silvermoon/a-guild/roster")) { + return Response.json({ + members: [ + { + character: { name: "", realm: { slug: "silvermoon" }, level: 80 } + }, + { + character: { + name: "Keeper", + realm: { slug: "Silvermoon" }, + playable_class: { id: 8 }, + level: 70 + } + } + ] + }); + } + throw new Error(`unexpected endpoint: ${url.pathname}`); + }); + + await expect(gateway.getGuildRoster(key)).resolves.toEqual([ + { + key: { region: "eu", realm: "silvermoon", name: "keeper" }, + displayName: "Keeper", + className: "Mage", + level: 70 + } + ]); }); it("returns an empty roster when the root has no guild", async () => { diff --git a/packages/blizzard/src/client.ts b/packages/blizzard/src/client.ts index b509355..cad70a2 100644 --- a/packages/blizzard/src/client.ts +++ b/packages/blizzard/src/client.ts @@ -79,7 +79,8 @@ function finiteNumber(value: unknown): number | null { function normalizedRosterCharacter( value: unknown, - region: CharacterKey["region"] + region: CharacterKey["region"], + classNames: ReadonlyMap ): BlizzardRosterCharacter | null { const member = valueRecord(value); const character = member && valueRecord(member.character); @@ -87,7 +88,12 @@ function normalizedRosterCharacter( const playableClass = character && valueRecord(character.playable_class); const displayName = character && nonEmptyString(character.name); const realmSlug = realm && nonEmptyString(realm.slug); - const className = playableClass && nonEmptyString(playableClass.name); + // A roster member carries only its class id; Blizzard sends the name from the + // static playable-class index, not from the roster itself. + const classId = playableClass && finiteNumber(playableClass.id); + const className = + (playableClass && nonEmptyString(playableClass.name)) ?? + (classId === null ? null : (classNames.get(classId) ?? null)); const level = character && finiteNumber(character.level); if ( !displayName || @@ -138,6 +144,7 @@ export function createBlizzardClient( options: CreateBlizzardClientOptions ): BlizzardGateway { let cachedToken: AccessToken | undefined; + let cachedClassNames: ReadonlyMap | undefined; async function accessToken(signal?: AbortSignal): Promise { if (cachedToken && cachedToken.expiresAt > Date.now()) { @@ -243,6 +250,43 @@ export function createBlizzardClient( return url; } + function playableClassIndexUrl(region: CharacterKey["region"]): URL { + const url = new URL( + "/data/wow/playable-class/index", + options.baseUrl ?? `https://${region}.api.blizzard.com` + ); + url.searchParams.set("namespace", `static-${region}`); + url.searchParams.set("locale", "en_GB"); + return url; + } + + async function playableClassNames( + region: CharacterKey["region"], + signal?: AbortSignal, + onProfileRequest?: BlizzardProfileRequestObserver + ): Promise> { + // The class list changes at most once per expansion, so one lookup per + // process serves every sweep. It is still accounted as a request. + cachedClassNames ??= await request( + playableClassIndexUrl(region), + (value) => { + const body = valueRecord(value); + if (!body || !Array.isArray(body.classes)) return null; + const names = new Map(); + for (const entry of body.classes) { + const playableClass = valueRecord(entry); + const id = playableClass && finiteNumber(playableClass.id); + const name = playableClass && nonEmptyString(playableClass.name); + if (id !== null && name) names.set(id, name); + } + return names.size > 0 ? names : null; + }, + signal, + onProfileRequest + ); + return cachedClassNames; + } + function rosterUrl( region: CharacterKey["region"], realm: string, @@ -278,17 +322,27 @@ export function createBlizzardClient( if (!name || !realmSlug) throw createBlizzardError({ kind: "schema_drift" }); + const classNames = await playableClassNames( + key.region, + signal, + onProfileRequest + ); + return request( rosterUrl(key.region, realmSlug, name), (value) => { const roster = valueRecord(value); if (!roster || !Array.isArray(roster.members)) return null; - const members = roster.members.map((member) => - normalizedRosterCharacter(member, key.region) - ); - return members.every((member) => member !== null) - ? (members as BlizzardRosterCharacter[]) - : null; + // A member the key space cannot represent is skipped, not fatal: one + // such member would otherwise abandon the entire sweep. Only a missing + // members array is structural change. + return roster.members + .map((member) => + normalizedRosterCharacter(member, key.region, classNames) + ) + .filter( + (member): member is BlizzardRosterCharacter => member !== null + ); }, signal, onProfileRequest