From 40a6f45896805f36aabf534ebafb136e8c3efeb0 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:00:06 -0400 Subject: [PATCH 01/10] feat(api-client): add HypixelUnavailableException and a hypixelUnavailable response flag --- .../api-client/src/exceptions/hypixel.503.ts | 29 +++++++++++++++++++ packages/api-client/src/exceptions/index.ts | 1 + .../src/responses/get.guild.response.ts | 6 ++++ .../src/responses/get.player.response.ts | 6 ++++ .../src/responses/get.session.response.ts | 6 ++++ .../src/responses/get.status.response.ts | 6 ++++ 6 files changed, 54 insertions(+) create mode 100644 packages/api-client/src/exceptions/hypixel.503.ts diff --git a/packages/api-client/src/exceptions/hypixel.503.ts b/packages/api-client/src/exceptions/hypixel.503.ts new file mode 100644 index 000000000..effea2bf3 --- /dev/null +++ b/packages/api-client/src/exceptions/hypixel.503.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import { ApiProperty } from "@nestjs/swagger"; +import { ServiceUnavailableException } from "@nestjs/common"; + +export class HypixelUnavailableException extends ServiceUnavailableException { + @ApiProperty() + public statusCode: number; + + @ApiProperty() + public message: string; + + @ApiProperty() + public error: string; + + public constructor() { + super({ + statusCode: 503, + error: "Service Unavailable", + message: "hypixel", + }); + } +} diff --git a/packages/api-client/src/exceptions/index.ts b/packages/api-client/src/exceptions/index.ts index a2b39d183..75ec295c3 100644 --- a/packages/api-client/src/exceptions/index.ts +++ b/packages/api-client/src/exceptions/index.ts @@ -7,6 +7,7 @@ */ export * from "./guild.404.js"; +export * from "./hypixel.503.js"; export * from "./player.404.js"; export * from "./status.404.js"; export * from "./session.404.js"; diff --git a/packages/api-client/src/responses/get.guild.response.ts b/packages/api-client/src/responses/get.guild.response.ts index c8bc590c8..5ed74eefe 100644 --- a/packages/api-client/src/responses/get.guild.response.ts +++ b/packages/api-client/src/responses/get.guild.response.ts @@ -13,4 +13,10 @@ import { SuccessResponse } from "./success.response.js"; export class GetGuildResponse extends SuccessResponse { @ApiProperty() public guild: Guild; + + @ApiProperty({ + required: false, + description: "True when Hypixel failed or rate limited Statsify, so cached data was returned instead", + }) + public hypixelUnavailable?: boolean; } diff --git a/packages/api-client/src/responses/get.player.response.ts b/packages/api-client/src/responses/get.player.response.ts index b695023b5..8523c7ce7 100644 --- a/packages/api-client/src/responses/get.player.response.ts +++ b/packages/api-client/src/responses/get.player.response.ts @@ -13,4 +13,10 @@ import { SuccessResponse } from "./success.response.js"; export class GetPlayerResponse extends SuccessResponse { @ApiProperty() public player: Player; + + @ApiProperty({ + required: false, + description: "True when Hypixel failed or rate limited Statsify, so cached data was returned instead", + }) + public hypixelUnavailable?: boolean; } diff --git a/packages/api-client/src/responses/get.session.response.ts b/packages/api-client/src/responses/get.session.response.ts index aa6201a6c..272f386fb 100644 --- a/packages/api-client/src/responses/get.session.response.ts +++ b/packages/api-client/src/responses/get.session.response.ts @@ -13,4 +13,10 @@ import { SuccessResponse } from "./success.response.js"; export class GetSessionResponse extends SuccessResponse { @ApiProperty() public player: Player; + + @ApiProperty({ + required: false, + description: "True when Hypixel failed or rate limited Statsify, so cached data was returned instead", + }) + public hypixelUnavailable?: boolean; } diff --git a/packages/api-client/src/responses/get.status.response.ts b/packages/api-client/src/responses/get.status.response.ts index 5a2a4ffb3..0e329f53e 100644 --- a/packages/api-client/src/responses/get.status.response.ts +++ b/packages/api-client/src/responses/get.status.response.ts @@ -13,4 +13,10 @@ import { SuccessResponse } from "./success.response.js"; export class GetStatusResponse extends SuccessResponse { @ApiProperty() public status: Status; + + @ApiProperty({ + required: false, + description: "True when Hypixel failed or rate limited Statsify, so cached data was returned instead", + }) + public hypixelUnavailable?: boolean; } From 2f824e5b7cd0761c4a3452d1436a96ccc8e3de6a Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:00:06 -0400 Subject: [PATCH 02/10] fix(api): serve cached data when the Hypixel request fails --- apps/api/src/guild/guild.controller.ts | 6 +- apps/api/src/guild/guild.service.ts | 18 +++--- apps/api/src/hypixel/hypixel.service.ts | 43 ++++++-------- apps/api/src/player/player.controller.ts | 10 +++- apps/api/src/player/player.service.ts | 67 +++++++++++++++++----- apps/api/src/session/session.controller.ts | 7 ++- apps/api/src/session/session.service.ts | 15 +++-- 7 files changed, 111 insertions(+), 55 deletions(-) diff --git a/apps/api/src/guild/guild.controller.ts b/apps/api/src/guild/guild.controller.ts index ef7133bad..817c40c48 100644 --- a/apps/api/src/guild/guild.controller.ts +++ b/apps/api/src/guild/guild.controller.ts @@ -11,6 +11,7 @@ import { ApiNotFoundResponse, ApiOkResponse, ApiOperation, + ApiServiceUnavailableResponse, ApiTags, } from "@nestjs/swagger"; import { Auth } from "#auth"; @@ -19,6 +20,7 @@ import { ErrorResponse, GetGuildResponse, GuildNotFoundException, + HypixelUnavailableException, PlayerNotFoundException, } from "@statsify/api-client"; import { GuildDto } from "#dtos"; @@ -34,14 +36,16 @@ export class GuildController { @ApiBadRequestResponse({ type: ErrorResponse }) @ApiNotFoundResponse({ type: PlayerNotFoundException }) @ApiNotFoundResponse({ type: GuildNotFoundException }) + @ApiServiceUnavailableResponse({ type: HypixelUnavailableException }) @Get() @Auth({ weight: 120 }) public async getGuild(@Query() { guild: tag, type, cache }: GuildDto) { - const guild = await this.guildService.get(tag, type, cache); + const { guild, hypixelUnavailable } = await this.guildService.get(tag, type, cache); return { success: !!guild, guild, + hypixelUnavailable, }; } } diff --git a/apps/api/src/guild/guild.service.ts b/apps/api/src/guild/guild.service.ts index 7264f03fd..74bfed10d 100644 --- a/apps/api/src/guild/guild.service.ts +++ b/apps/api/src/guild/guild.service.ts @@ -17,7 +17,7 @@ import { GuildLeaderboardService } from "./leaderboards/guild-leaderboard.servic import { HypixelService } from "#hypixel"; import { InjectModel } from "@m8a/nestjs-typegoose"; import { InjectRedis } from "#redis"; -import { Injectable, ServiceUnavailableException } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; import { Logger } from "@statsify/logger"; import { PlayerService } from "#player"; import { Redis } from "ioredis"; @@ -41,7 +41,7 @@ export class GuildService { inputtedTag: string, type: GuildQuery, cache: CacheLevel - ): Promise { + ): Promise<{ guild: Guild; hypixelUnavailable: boolean }> { let [cachedGuild, tag, displayName] = await this.getCachedGuild(inputtedTag, type); // A player's saved guild id isn't cleared when they leave, so only use a cached guild that still lists them @@ -50,8 +50,8 @@ export class GuildService { if (cachedGuild && listsPlayer && this.hypixelService.shouldCache(cachedGuild.expiresAt, cache)) { return { - ...deserialize(Guild, flatten(cachedGuild)), - cached: true, + guild: { ...deserialize(Guild, flatten(cachedGuild)), cached: true }, + hypixelUnavailable: false, }; } @@ -72,16 +72,16 @@ export class GuildService { tag, type.toLowerCase() as Lowercase ); - } catch { + } catch (error) { // Serve the cache even if expired; an outage shouldn't clear or hide a guild if (cachedGuild && listsPlayer) { return { - ...deserialize(Guild, flatten(cachedGuild)), - cached: true, + guild: { ...deserialize(Guild, flatten(cachedGuild)), cached: true }, + hypixelUnavailable: true, }; } - throw new ServiceUnavailableException(); + throw error; } if (!guild) { @@ -179,7 +179,7 @@ export class GuildService { await this.guildLeaderboardService.addLeaderboards(Guild, serializedGuild, "id"); - return deserialize(Guild, flatGuild); + return { guild: deserialize(Guild, flatGuild), hypixelUnavailable: false }; } private async getCachedGuild( diff --git a/apps/api/src/hypixel/hypixel.service.ts b/apps/api/src/hypixel/hypixel.service.ts index 43092e3cb..5f6372afc 100644 --- a/apps/api/src/hypixel/hypixel.service.ts +++ b/apps/api/src/hypixel/hypixel.service.ts @@ -7,7 +7,7 @@ */ import * as Sentry from "@sentry/node"; -import { CacheLevel } from "@statsify/api-client"; +import { CacheLevel, HypixelUnavailableException } from "@statsify/api-client"; import { GameCounts, Guild, @@ -43,18 +43,11 @@ export class HypixelService { if (data.player) return new Player(data.player); return null; }), - catchError((err) => { - this.logger.error(err); - return of(null); - }) + catchError((err) => this.handleFailure(err, null)) ) ); } - /** - * Resolves to null when Hypixel says there is no guild or rejects the input. - * Other failures reject, so callers don't mistake an outage for a missing guild. - */ public getGuild(tag: string, type: "name" | "id" | "player") { return lastValueFrom( this.request("/guild", { [type]: tag }).pipe( @@ -62,14 +55,7 @@ export class HypixelService { if (data.guild) return new Guild(data.guild); return null; }), - catchError((err) => { - this.logger.error(err); - - const status = err.cause?.response?.status; - if (status === 400 || status === 422) return of(null); - - return throwError(() => err); - }) + catchError((err) => this.handleFailure(err, null)) ) ); } @@ -81,10 +67,7 @@ export class HypixelService { if (data.games) return data.games.map((game: APIData) => new RecentGame(game)); return []; }), - catchError((err) => { - this.logger.error(err); - return of([]); - }) + catchError((err) => this.handleFailure(err, [])) ) ); } @@ -96,10 +79,7 @@ export class HypixelService { if (data.session) return new Status(data.session); return null; }), - catchError((err) => { - this.logger.error(err); - return of(null); - }) + catchError((err) => this.handleFailure(err, null)) ) ); } @@ -149,6 +129,19 @@ export class HypixelService { return this.resources.get(resource); } + /** + * Hypixel rejecting the input means there is nothing to find. Any other failure rejects + * with a HypixelUnavailableException, so callers don't mistake an outage for missing data. + */ + private handleFailure(err: any, notFound: T): Observable { + this.logger.error(err); + + const status = err.cause?.response?.status; + if (status === 400 || status === 422) return of(notFound); + + return throwError(() => new HypixelUnavailableException()); + } + private request(url: string, params?: Record): Observable { const transaction = Sentry.getCurrentHub().getScope()?.getTransaction(); diff --git a/apps/api/src/player/player.controller.ts b/apps/api/src/player/player.controller.ts index 552b8c0df..e3d7f79e8 100644 --- a/apps/api/src/player/player.controller.ts +++ b/apps/api/src/player/player.controller.ts @@ -11,6 +11,7 @@ import { ApiNotFoundResponse, ApiOkResponse, ApiOperation, + ApiServiceUnavailableResponse, ApiTags, } from "@nestjs/swagger"; import { Auth, AuthRole } from "#auth"; @@ -26,6 +27,7 @@ import { ErrorResponse, GetPlayerResponse, GetStatusResponse, + HypixelUnavailableException, PlayerNotFoundException, StatusNotFoundException, } from "@statsify/api-client"; @@ -41,16 +43,18 @@ export class PlayerController { @ApiOkResponse({ type: GetPlayerResponse }) @ApiBadRequestResponse({ type: ErrorResponse }) @ApiNotFoundResponse({ type: PlayerNotFoundException }) + @ApiServiceUnavailableResponse({ type: HypixelUnavailableException }) @Auth() @Get() public async getPlayer(@Query() { player: tag, cache }: CachedPlayerDto) { - const player = await this.playerService.get(tag, cache); + const { player, hypixelError } = await this.playerService.getWithFallback(tag, cache); if (!player) throw new PlayerNotFoundException(); return { success: !!player, player, + hypixelUnavailable: Boolean(hypixelError), }; } @@ -86,14 +90,16 @@ export class PlayerController { @ApiBadRequestResponse({ type: ErrorResponse }) @ApiNotFoundResponse({ type: StatusNotFoundException }) @ApiNotFoundResponse({ type: PlayerNotFoundException }) + @ApiServiceUnavailableResponse({ type: HypixelUnavailableException }) @Auth({ weight: 2 }) @Get("/status") public async getStatus(@Query() { player: tag }: PlayerDto) { - const status = await this.playerService.getStatus(tag); + const { status, hypixelUnavailable } = await this.playerService.getStatus(tag); return { success: !!status, status, + hypixelUnavailable, }; } } diff --git a/apps/api/src/player/player.service.ts b/apps/api/src/player/player.service.ts index 6bc9d8953..3396330f3 100644 --- a/apps/api/src/player/player.service.ts +++ b/apps/api/src/player/player.service.ts @@ -12,7 +12,11 @@ import { type Flatten, flatten, } from "@statsify/util"; -import { CacheLevel, StatusNotFoundException } from "@statsify/api-client"; +import { + CacheLevel, + HypixelUnavailableException, + StatusNotFoundException, +} from "@statsify/api-client"; import { HypixelService } from "#hypixel"; import { Inject, @@ -21,7 +25,7 @@ import { forwardRef, } from "@nestjs/common"; import { InjectModel } from "@m8a/nestjs-typegoose"; -import { Player, deserialize, serialize } from "@statsify/schemas"; +import { Player, Status, deserialize, serialize } from "@statsify/schemas"; import { PlayerLeaderboardService } from "./leaderboards/player-leaderboard.service.js"; import { PlayerSearchService, @@ -31,6 +35,12 @@ import type { ReturnModelType } from "@typegoose/typegoose"; type PlayerModel = ReturnModelType; +export interface PlayerLookup { + player: Player | null; + /** Set when Hypixel failed and `player` is the cached copy */ + hypixelError?: HypixelUnavailableException; +} + @Injectable() export class PlayerService { public constructor( @@ -52,6 +62,19 @@ export class PlayerService { cacheLevel: CacheLevel, selector?: Record ): Promise { + const { player } = await this.getWithFallback(tag, cacheLevel, selector); + return player; + } + + /** + * Same as `get`, but also says whether the cached player was served because Hypixel failed. + * Throws a HypixelUnavailableException when Hypixel failed and there is no cached player. + */ + public async getWithFallback( + tag: string, + cacheLevel: CacheLevel, + selector?: Record + ): Promise { // ensure we have all the necessary data to replace the player document if we don't do a cache only request if (selector && Object.keys(selector).length > 0) { selector.uuid = true; @@ -67,12 +90,21 @@ export class PlayerService { mongoPlayer && this.hypixelService.shouldCache(mongoPlayer.expiresAt, cacheLevel) ) { - return deserialize(Player, mongoPlayer); + return { player: deserialize(Player, mongoPlayer) }; } - const player = await this.hypixelService.getPlayer( - mongoPlayer?.uuid ?? tag - ); + let player: Player | null; + + try { + player = await this.hypixelService.getPlayer(mongoPlayer?.uuid ?? tag); + } catch (error) { + if (!mongoPlayer) throw error; + + return { + player: deserialize(Player, mongoPlayer), + hypixelError: error as HypixelUnavailableException, + }; + } if (player) { player.expiresAt = Date.now() + 120_000; @@ -86,12 +118,12 @@ export class PlayerService { flatPlayer.isNew = !mongoPlayer; - return deserialize(Player, flatPlayer); + return { player: deserialize(Player, flatPlayer) }; } else if (mongoPlayer) { - return deserialize(Player, mongoPlayer); + return { player: deserialize(Player, mongoPlayer) }; } - return null; + return { player: null }; } public async update(data: APIData) { @@ -121,7 +153,7 @@ export class PlayerService { } public async getStatus(tag: string) { - const player = await this.get(tag, CacheLevel.CACHE, { + const { player, hypixelError } = await this.getWithFallback(tag, CacheLevel.CACHE, { uuid: true, displayName: true, prefixName: true, @@ -130,9 +162,18 @@ export class PlayerService { if (!player) throw new NotFoundException("player"); + let hypixelUnavailable = Boolean(hypixelError); + const [status, games] = await Promise.all([ - this.hypixelService.getStatus(player.uuid), - this.hypixelService.getRecentGames(player.uuid), + this.hypixelService.getStatus(player.uuid).catch(() => { + hypixelUnavailable = true; + + // Hypixel's live status isn't stored, so fall back to the login info on the player + const { online, lastGame } = player.status; + return new Status({ online, gameType: online ? lastGame.code : undefined }); + }), + // Recent games are optional on the status image, so a failure just leaves them out + this.hypixelService.getRecentGames(player.uuid).catch(() => []), ]); if (!status) throw new StatusNotFoundException(player); @@ -143,7 +184,7 @@ export class PlayerService { status.actions = player.status; status.recentGames = games; - return status; + return { status, hypixelUnavailable }; } /** diff --git a/apps/api/src/session/session.controller.ts b/apps/api/src/session/session.controller.ts index 2a6cb84e0..4de3b75ca 100644 --- a/apps/api/src/session/session.controller.ts +++ b/apps/api/src/session/session.controller.ts @@ -10,6 +10,7 @@ import { ApiBadRequestResponse, ApiOkResponse, ApiOperation, + ApiServiceUnavailableResponse, ApiTags, } from "@nestjs/swagger"; import { Auth, AuthRole } from "#auth"; @@ -18,6 +19,7 @@ import { ErrorResponse, GetPlayerResponse, GetSessionResponse, + HypixelUnavailableException, SuccessResponse, } from "@statsify/api-client"; import { PlayerDto, SessionDto, UserIdDto } from "#dtos"; @@ -31,20 +33,23 @@ export class SessionController { @ApiOperation({ summary: "Get the Session stats of a Player" }) @ApiOkResponse({ type: GetSessionResponse }) @ApiBadRequestResponse({ type: ErrorResponse }) + @ApiServiceUnavailableResponse({ type: HypixelUnavailableException }) @Get() @Auth({ weight: 2 }) public async getSession(@Query() { player: tag, userUuid }: SessionDto) { - const player = await this.sessionService.get(tag, userUuid); + const { player, hypixelUnavailable } = await this.sessionService.get(tag, userUuid); return { success: !!player, player, + hypixelUnavailable, }; } @ApiOperation({ summary: "Reset the Session stats of a Player" }) @ApiOkResponse({ type: GetPlayerResponse }) @ApiBadRequestResponse({ type: ErrorResponse }) + @ApiServiceUnavailableResponse({ type: HypixelUnavailableException }) @Patch() @Auth({ role: AuthRole.MEMBER }) public async resetSession( diff --git a/apps/api/src/session/session.service.ts b/apps/api/src/session/session.service.ts index 0675b3826..82764a35f 100644 --- a/apps/api/src/session/session.service.ts +++ b/apps/api/src/session/session.service.ts @@ -43,7 +43,9 @@ export class SessionService { ) {} public async getAndReset(tag: string) { - const player = await this.playerService.get(tag, CacheLevel.LIVE); + const { player, hypixelError } = await this.playerService.getWithFallback(tag, CacheLevel.LIVE); + // A cached baseline would count games played before the reset in the new session + if (hypixelError) throw hypixelError; if (!player) throw new PlayerNotFoundException(); return this.resetPlayer(player); } @@ -67,7 +69,7 @@ export class SessionService { public async get( tag: string, userUuid?: string - ): Promise { + ): Promise<{ player: Player; hypixelUnavailable: boolean }> { const player = await this.playerService.get(tag, CacheLevel.CACHE_ONLY, { uuid: true, displayName: true, @@ -84,13 +86,18 @@ export class SessionService { if (!oldPlayer && userUuid !== player.uuid) throw new SessionNotFoundException(player.uuid, player.displayName); - const newPlayer = await this.playerService.get(player.uuid, CacheLevel.LIVE); + const { player: newPlayer, hypixelError } = await this.playerService.getWithFallback( + player.uuid, + CacheLevel.LIVE + ); if (!newPlayer) throw new PlayerNotFoundException(); if (oldPlayer) { oldPlayer = deserialize(Player, flatten(oldPlayer)); } else { + // Same as getAndReset, a new session needs a live baseline + if (hypixelError) throw hypixelError; oldPlayer = await this.resetPlayer(newPlayer); isNew = true; } @@ -100,7 +107,7 @@ export class SessionService { merged.sessionReset = oldPlayer.sessionReset; merged.isNew = isNew; - return merged; + return { player: merged, hypixelUnavailable: Boolean(hypixelError) }; } public async delete(discordId: string) { From 1d47f614e0acc409564e64921675a3d9fc807918 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:00:06 -0400 Subject: [PATCH 03/10] fix(discord): tell users when Hypixel is not responding --- locales/en-US/default.json | 4 ++++ packages/discord/src/services/api.service.ts | 22 ++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/locales/en-US/default.json b/locales/en-US/default.json index 4892217ee..123369985 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -375,6 +375,10 @@ "description": "You need to purchase a higher premium tier to unlock this icon!", "title": "Higher Tier Required" }, + "hypixelUnavailable": { + "description": "Hypixel isn't responding right now, try again in a bit.", + "title": "Hypixel Unavailable" + }, "invalidGuild": { "description_id": "A guild with the id of `{{tag}}` could not be found!", "description_name": "A guild by the name of `{{tag}}` could not be found!", diff --git a/packages/discord/src/services/api.service.ts b/packages/discord/src/services/api.service.ts index 2e2ec01ae..f433e513a 100644 --- a/packages/discord/src/services/api.service.ts +++ b/packages/discord/src/services/api.service.ts @@ -14,6 +14,7 @@ import { GUILD_ID_REGEX, GuildNotFoundException, GuildQuery, + HypixelUnavailableException, LeaderboardQuery, PlayerNotFoundException, SessionNotFoundException, @@ -54,6 +55,7 @@ export class ApiService extends StatsifyApiService { const error = err.response.data as PlayerNotFoundException; if (error.message === "player") throw this.missingPlayer(type, tag); + if (error.message === "hypixel") throw this.hypixelUnavailable(); throw this.unknownError(); }); @@ -72,6 +74,7 @@ export class ApiService extends StatsifyApiService { const error = err.response.data as PlayerNotFoundException | SessionNotFoundException; if (error.message === "player") throw this.missingPlayer(type, tag); + if (error.message === "hypixel") throw this.hypixelUnavailable(); if (error.message === "session") { const { displayName } = error as SessionNotFoundException; @@ -103,6 +106,7 @@ export class ApiService extends StatsifyApiService { PlayerNotFoundException; if (error.message === "player") throw this.missingPlayer(type, tag); + if (error.message === "hypixel") throw this.hypixelUnavailable(); if (error.message === "status") { const { displayName } = error as StatusNotFoundException; @@ -179,6 +183,7 @@ export class ApiService extends StatsifyApiService { } if (error.message === "player") throw this.missingPlayer(playerType, input); + if (error.message === "hypixel") throw this.hypixelUnavailable(); throw this.unknownError(); }); @@ -191,6 +196,10 @@ export class ApiService extends StatsifyApiService { ) { return super.getPlayerLeaderboard(field, input, type).catch((err: AxiosError) => { if (!err.response || !err.response.data) throw this.unknownError(); + + const error = err.response.data as HypixelUnavailableException; + if (error.message === "hypixel") throw this.hypixelUnavailable(); + if ((err.response.data as PlayerNotFoundException).statusCode === 404) return null; throw new ErrorMessage("errors.leaderboardNotFound"); }); @@ -228,6 +237,15 @@ export class ApiService extends StatsifyApiService { }); } + public override resetPlayerSession(tag: string) { + return super.resetPlayerSession(tag).catch((err: AxiosError) => { + const error = err.response?.data as HypixelUnavailableException | undefined; + if (error?.message === "hypixel") throw this.hypixelUnavailable(); + + throw err; + }); + } + public override async deletePlayerSession(id: string): Promise { return super.deletePlayerSession(id).catch(() => { throw new ErrorMessage("verification.requiredVerification"); @@ -316,6 +334,10 @@ export class ApiService extends StatsifyApiService { return new ErrorMessage("errors.unknown"); } + public hypixelUnavailable() { + return new ErrorMessage("errors.hypixelUnavailable"); + } + private isDiscordId(tag: string) { return Boolean(/^<@!?(\d{17,19})>$/.test(tag)) || Boolean(/^\d{17,19}$/m.test(tag)); } From c974de4f568dc8671801fd213b14f7e0dc74a805 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:13:08 -0400 Subject: [PATCH 04/10] fix(api): show the Hypixel error on /status instead of a status built from cache --- apps/api/src/player/player.service.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/apps/api/src/player/player.service.ts b/apps/api/src/player/player.service.ts index 3396330f3..1be1ee681 100644 --- a/apps/api/src/player/player.service.ts +++ b/apps/api/src/player/player.service.ts @@ -25,7 +25,7 @@ import { forwardRef, } from "@nestjs/common"; import { InjectModel } from "@m8a/nestjs-typegoose"; -import { Player, Status, deserialize, serialize } from "@statsify/schemas"; +import { Player, deserialize, serialize } from "@statsify/schemas"; import { PlayerLeaderboardService } from "./leaderboards/player-leaderboard.service.js"; import { PlayerSearchService, @@ -162,16 +162,8 @@ export class PlayerService { if (!player) throw new NotFoundException("player"); - let hypixelUnavailable = Boolean(hypixelError); - const [status, games] = await Promise.all([ - this.hypixelService.getStatus(player.uuid).catch(() => { - hypixelUnavailable = true; - - // Hypixel's live status isn't stored, so fall back to the login info on the player - const { online, lastGame } = player.status; - return new Status({ online, gameType: online ? lastGame.code : undefined }); - }), + this.hypixelService.getStatus(player.uuid), // Recent games are optional on the status image, so a failure just leaves them out this.hypixelService.getRecentGames(player.uuid).catch(() => []), ]); @@ -184,7 +176,7 @@ export class PlayerService { status.actions = player.status; status.recentGames = games; - return { status, hypixelUnavailable }; + return { status, hypixelUnavailable: Boolean(hypixelError) }; } /** From 7be689edaff947cf194282885589b9e9fd5fdb6a Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:29:51 -0400 Subject: [PATCH 05/10] fix(locales): reword the Hypixel unavailable error --- locales/en-US/default.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/locales/en-US/default.json b/locales/en-US/default.json index 123369985..f0726985c 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -376,8 +376,8 @@ "title": "Higher Tier Required" }, "hypixelUnavailable": { - "description": "Hypixel isn't responding right now, try again in a bit.", - "title": "Hypixel Unavailable" + "description": "The Hypixel API is having trouble right now. Please try again in a few minutes!", + "title": "Hypixel API Unavailable" }, "invalidGuild": { "description_id": "A guild with the id of `{{tag}}` could not be found!", From b2485164b6f0a7e1e76055f6e4d46d155eff5566 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:51:59 -0400 Subject: [PATCH 06/10] refactor(api): build the cached guild response in one place --- apps/api/src/guild/guild.service.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/apps/api/src/guild/guild.service.ts b/apps/api/src/guild/guild.service.ts index 74bfed10d..233bffb4c 100644 --- a/apps/api/src/guild/guild.service.ts +++ b/apps/api/src/guild/guild.service.ts @@ -49,10 +49,7 @@ export class GuildService { type !== GuildQuery.PLAYER || cachedGuild?.members.some((member) => member.uuid === tag); if (cachedGuild && listsPlayer && this.hypixelService.shouldCache(cachedGuild.expiresAt, cache)) { - return { - guild: { ...deserialize(Guild, flatten(cachedGuild)), cached: true }, - hypixelUnavailable: false, - }; + return this.fromCache(cachedGuild, false); } // Most players have no guild, so that answer is cached as long as a guild is @@ -74,13 +71,7 @@ export class GuildService { ); } catch (error) { // Serve the cache even if expired; an outage shouldn't clear or hide a guild - if (cachedGuild && listsPlayer) { - return { - guild: { ...deserialize(Guild, flatten(cachedGuild)), cached: true }, - hypixelUnavailable: true, - }; - } - + if (cachedGuild && listsPlayer) return this.fromCache(cachedGuild, true); throw error; } @@ -182,6 +173,13 @@ export class GuildService { return { guild: deserialize(Guild, flatGuild), hypixelUnavailable: false }; } + private fromCache(cachedGuild: Guild, hypixelUnavailable: boolean) { + return { + guild: { ...deserialize(Guild, flatten(cachedGuild)), cached: true }, + hypixelUnavailable, + }; + } + private async getCachedGuild( tag: string, type: GuildQuery From b63cfb6d4ffff112826d1d844e70fa9da7ce73c7 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:52:19 -0400 Subject: [PATCH 07/10] refactor(api): trim the Hypixel service comments --- apps/api/src/hypixel/hypixel.service.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/api/src/hypixel/hypixel.service.ts b/apps/api/src/hypixel/hypixel.service.ts index 5f6372afc..73a431a6a 100644 --- a/apps/api/src/hypixel/hypixel.service.ts +++ b/apps/api/src/hypixel/hypixel.service.ts @@ -129,13 +129,10 @@ export class HypixelService { return this.resources.get(resource); } - /** - * Hypixel rejecting the input means there is nothing to find. Any other failure rejects - * with a HypixelUnavailableException, so callers don't mistake an outage for missing data. - */ private handleFailure(err: any, notFound: T): Observable { this.logger.error(err); + // Hypixel rejecting the input means there is nothing to find const status = err.cause?.response?.status; if (status === 400 || status === 422) return of(notFound); From 32e9991c5910c560d4c2f3eb0358624c9b3cb9cd Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:53:50 -0400 Subject: [PATCH 08/10] refactor(api): drop the unread hypixelUnavailable flag from /status --- apps/api/src/player/player.controller.ts | 3 +-- apps/api/src/player/player.service.ts | 4 ++-- packages/api-client/src/responses/get.status.response.ts | 6 ------ 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/apps/api/src/player/player.controller.ts b/apps/api/src/player/player.controller.ts index e3d7f79e8..45156f614 100644 --- a/apps/api/src/player/player.controller.ts +++ b/apps/api/src/player/player.controller.ts @@ -94,12 +94,11 @@ export class PlayerController { @Auth({ weight: 2 }) @Get("/status") public async getStatus(@Query() { player: tag }: PlayerDto) { - const { status, hypixelUnavailable } = await this.playerService.getStatus(tag); + const status = await this.playerService.getStatus(tag); return { success: !!status, status, - hypixelUnavailable, }; } } diff --git a/apps/api/src/player/player.service.ts b/apps/api/src/player/player.service.ts index 1be1ee681..7306699cf 100644 --- a/apps/api/src/player/player.service.ts +++ b/apps/api/src/player/player.service.ts @@ -153,7 +153,7 @@ export class PlayerService { } public async getStatus(tag: string) { - const { player, hypixelError } = await this.getWithFallback(tag, CacheLevel.CACHE, { + const player = await this.get(tag, CacheLevel.CACHE, { uuid: true, displayName: true, prefixName: true, @@ -176,7 +176,7 @@ export class PlayerService { status.actions = player.status; status.recentGames = games; - return { status, hypixelUnavailable: Boolean(hypixelError) }; + return status; } /** diff --git a/packages/api-client/src/responses/get.status.response.ts b/packages/api-client/src/responses/get.status.response.ts index 0e329f53e..5a2a4ffb3 100644 --- a/packages/api-client/src/responses/get.status.response.ts +++ b/packages/api-client/src/responses/get.status.response.ts @@ -13,10 +13,4 @@ import { SuccessResponse } from "./success.response.js"; export class GetStatusResponse extends SuccessResponse { @ApiProperty() public status: Status; - - @ApiProperty({ - required: false, - description: "True when Hypixel failed or rate limited Statsify, so cached data was returned instead", - }) - public hypixelUnavailable?: boolean; } From 2fb6156f3ccfd54aefa3ecd893f89dd2d81209aa Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:55:10 -0500 Subject: [PATCH 09/10] refactor(api): drop comments that restate the Hypixel fallback code --- apps/api/src/player/player.service.ts | 5 ----- apps/api/src/session/session.service.ts | 1 - 2 files changed, 6 deletions(-) diff --git a/apps/api/src/player/player.service.ts b/apps/api/src/player/player.service.ts index 7306699cf..39c89d1fe 100644 --- a/apps/api/src/player/player.service.ts +++ b/apps/api/src/player/player.service.ts @@ -66,10 +66,6 @@ export class PlayerService { return player; } - /** - * Same as `get`, but also says whether the cached player was served because Hypixel failed. - * Throws a HypixelUnavailableException when Hypixel failed and there is no cached player. - */ public async getWithFallback( tag: string, cacheLevel: CacheLevel, @@ -164,7 +160,6 @@ export class PlayerService { const [status, games] = await Promise.all([ this.hypixelService.getStatus(player.uuid), - // Recent games are optional on the status image, so a failure just leaves them out this.hypixelService.getRecentGames(player.uuid).catch(() => []), ]); diff --git a/apps/api/src/session/session.service.ts b/apps/api/src/session/session.service.ts index 82764a35f..50fc93c18 100644 --- a/apps/api/src/session/session.service.ts +++ b/apps/api/src/session/session.service.ts @@ -96,7 +96,6 @@ export class SessionService { if (oldPlayer) { oldPlayer = deserialize(Player, flatten(oldPlayer)); } else { - // Same as getAndReset, a new session needs a live baseline if (hypixelError) throw hypixelError; oldPlayer = await this.resetPlayer(newPlayer); isNew = true; From 7a8f2f6129805bffd37a8162a4ae2fa5c5c8551e Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:00:00 -0500 Subject: [PATCH 10/10] docs(api): keep the getWithFallback throws contract --- apps/api/src/player/player.service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/api/src/player/player.service.ts b/apps/api/src/player/player.service.ts index 39c89d1fe..781de88d9 100644 --- a/apps/api/src/player/player.service.ts +++ b/apps/api/src/player/player.service.ts @@ -66,6 +66,9 @@ export class PlayerService { return player; } + /** + * Throws a HypixelUnavailableException when Hypixel failed and there is no cached player. + */ public async getWithFallback( tag: string, cacheLevel: CacheLevel,