From 09a04dc328ac709d4a97120e427c8aa39d3a7363 Mon Sep 17 00:00:00 2001 From: Ryan Wong Date: Mon, 10 Aug 2026 17:50:37 +0100 Subject: [PATCH] fix(domain): treat unreadable Blizzard profiles as ordinary A 404 from the Profile API turned the whole sweep into a retryable upstream failure, so it published nothing and retried from the root. The live measurement saw 23 of 393 candidates 404, which means in practice no real sweep would ever have published. Skip such a candidate and keep the request it already spent. For the root's own roster or achievements, report an empty sweep instead: the root cannot be fingerprinted at all, so its Raider.IO snapshot should publish rather than the run being stranded on a retry that cannot succeed. Unguilded roots already return an empty roster and are unaffected. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qh8Zb2HnaxebWrRLUMoAiv --- .../domain/src/fingerprint-discovery.test.ts | 67 +++++++++++++++++++ packages/domain/src/fingerprint-discovery.ts | 57 +++++++++++++--- 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/packages/domain/src/fingerprint-discovery.test.ts b/packages/domain/src/fingerprint-discovery.test.ts index bf22317..23ae781 100644 --- a/packages/domain/src/fingerprint-discovery.test.ts +++ b/packages/domain/src/fingerprint-discovery.test.ts @@ -156,6 +156,73 @@ describe("discoverFingerprintMatches", () => { }); }); + it("finds nothing rather than failing when the root has no readable roster", async () => { + // Break caught: Blizzard 404s a character it holds no current profile for, + // which would discard an otherwise good Raider.IO snapshot and retry a root + // that can never be swept. + await expect( + discoverFingerprintMatches( + root, + { + async getGuildRoster() { + throw Object.assign(new Error("missing"), { kind: "not_found" }); + }, + async getAchievementFingerprint() { + throw new Error("unreachable"); + } + }, + options + ) + ).resolves.toEqual({ kind: "matched", requestsUsed: 1, characters: [] }); + }); + + it("finds nothing rather than failing when the root has no readable profile", async () => { + // Break caught: the same 404 on the root's own achievements would strand the + // run instead of publishing its Raider.IO result. + await expect( + discoverFingerprintMatches( + root, + gatewayFor([candidate(matchingKey)], {}), + options + ) + ).resolves.toEqual({ kind: "matched", requestsUsed: 2, characters: [] }); + }); + + it("skips a candidate with no readable profile and keeps sweeping", async () => { + // Break caught: a roster member whose achievements are unreadable is + // ordinary — the measured live sweep saw 23 of 393 — so treating one as an + // upstream failure would abandon every real sweep and publish nothing. + const outcome = await discoverFingerprintMatches( + root, + gatewayFor( + [ + candidate({ region: "eu", realm: "silvermoon", name: "a-missing" }), + candidate(matchingKey) + ], + { + [keyId(root)]: fingerprint(200), + [keyId(matchingKey)]: fingerprint(200) + } + ), + { ...options, requestCap: 4, isSuppressed: async () => false } + ); + + expect(outcome).toEqual({ + kind: "matched", + requestsUsed: 4, + characters: [ + { + key: matchingKey, + displayName: "matching", + className: "Mage", + level: 80, + raiderIoUrl: "https://raider.io/characters/eu/silvermoon/matching", + source: "fingerprint" + } + ] + }); + }); + it("rechecks privacy immediately before admitting a matched candidate", async () => { // Break caught: a privacy-hidden designation that lands while the candidate // fingerprint is being fetched could still be retained in the result. diff --git a/packages/domain/src/fingerprint-discovery.ts b/packages/domain/src/fingerprint-discovery.ts index 58eef1b..b8273c1 100644 --- a/packages/domain/src/fingerprint-discovery.ts +++ b/packages/domain/src/fingerprint-discovery.ts @@ -50,6 +50,15 @@ export type DiscoverFingerprintMatchesOptions = { signal?: AbortSignal; }; +function isNotFound(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "kind" in error && + error.kind === "not_found" + ); +} + function isCharacterKey(value: unknown): value is CharacterKey { if (typeof value !== "object" || value === null) return false; @@ -211,17 +220,37 @@ export async function discoverFingerprintMatches( } try { - const roster = await request(() => - gateway.getGuildRoster(root, options.signal) - ); + // Blizzard holds no current profile for plenty of characters Raider.IO + // knows, and answers 404. That makes this root unsweepable rather than the + // upstream broken, so report an empty sweep and let its Raider.IO snapshot + // publish instead of stranding the run on a retry that cannot succeed. + let roster: readonly FingerprintCandidate[] | typeof budgetExhausted; + try { + roster = await request(() => + gateway.getGuildRoster(root, options.signal) + ); + } catch (error) { + if (isNotFound(error)) { + return { kind: "matched", characters: [], requestsUsed }; + } + throw error; + } if (roster === budgetExhausted) { return { kind: "capped", characters: [], requestsUsed }; } if (!isCandidateList(roster)) throw { kind: "schema_drift" }; - const rootFingerprint = await request(() => - gateway.getAchievementFingerprint(root, options.signal) - ); + let rootFingerprint: ReadonlyMap | typeof budgetExhausted; + try { + rootFingerprint = await request(() => + gateway.getAchievementFingerprint(root, options.signal) + ); + } catch (error) { + if (isNotFound(error)) { + return { kind: "matched", characters: [], requestsUsed }; + } + throw error; + } if (rootFingerprint === budgetExhausted) { return { kind: "capped", characters: [], requestsUsed }; } @@ -249,9 +278,19 @@ export async function discoverFingerprintMatches( throwIfAborted(); if (isPrivacyHidden) continue; - const candidateFingerprint = await request(() => - gateway.getAchievementFingerprint(candidate.key, options.signal) - ); + // A roster member with no readable achievement profile is ordinary, not an + // upstream fault: the measured live sweep saw 23 of 393 candidates return + // one. Skip the candidate and keep the request it already consumed. + let candidateFingerprint: + ReadonlyMap | typeof budgetExhausted; + try { + candidateFingerprint = await request(() => + gateway.getAchievementFingerprint(candidate.key, options.signal) + ); + } catch (error) { + if (isNotFound(error)) continue; + throw error; + } if (candidateFingerprint === budgetExhausted) break; if (!isFingerprint(candidateFingerprint)) throw { kind: "schema_drift" };