Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions packages/blizzard/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,22 @@ 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: [
{
character: {
name: "Alt",
realm: { slug: "Silvermoon" },
playable_class: { name: "Mage" },
playable_class: { id: 8 },
level: 80
}
}
Expand All @@ -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 () => {
Expand Down
70 changes: 62 additions & 8 deletions packages/blizzard/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,21 @@ function finiteNumber(value: unknown): number | null {

function normalizedRosterCharacter(
value: unknown,
region: CharacterKey["region"]
region: CharacterKey["region"],
classNames: ReadonlyMap<number, string>
): BlizzardRosterCharacter | null {
const member = valueRecord(value);
const character = member && valueRecord(member.character);
const realm = character && valueRecord(character.realm);
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 ||
Expand Down Expand Up @@ -138,6 +144,7 @@ export function createBlizzardClient(
options: CreateBlizzardClientOptions
): BlizzardGateway {
let cachedToken: AccessToken | undefined;
let cachedClassNames: ReadonlyMap<number, string> | undefined;

async function accessToken(signal?: AbortSignal): Promise<string> {
if (cachedToken && cachedToken.expiresAt > Date.now()) {
Expand Down Expand Up @@ -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<ReadonlyMap<number, string>> {
// 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<number, string>();
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,
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions packages/raiderio/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type FixtureName =
| "character-renamed-root"
| "profile-valid"
| "profile-invalid"
| "profile-forbidden"
| "claimed-characters"
| "claimed-characters-out-of-scope"
| "missing-character"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion packages/raiderio/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/raiderio/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export type RaiderIoFailure =
| { kind: "not_found" }
| { kind: "forbidden" }
| {
kind: "transient";
status?: number;
Expand All @@ -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"
);
Expand Down
9 changes: 9 additions & 0 deletions tests/fixtures/raiderio/profile-forbidden.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading