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" + } +}