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 42ed32ee0..a53d30af4 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"; @@ -43,7 +43,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 @@ -51,10 +51,7 @@ export class GuildService { type !== GuildQuery.PLAYER || cachedGuild?.members.some((member) => member.uuid === tag); if (cachedGuild && listsPlayer && this.hypixelService.shouldCache(cachedGuild.expiresAt, cache)) { - return { - ...deserialize(Guild, flatten(cachedGuild)), - cached: true, - }; + return this.fromCache(cachedGuild, false); } if ( @@ -73,15 +70,9 @@ export class GuildService { tag, type.toLowerCase() as Lowercase ); - } catch { - if (cachedGuild && listsPlayer) { - return { - ...deserialize(Guild, flatten(cachedGuild)), - cached: true, - }; - } - - throw new ServiceUnavailableException(); + } catch (error) { + if (cachedGuild && listsPlayer) return this.fromCache(cachedGuild, true); + throw error; } if (!guild) { @@ -179,7 +170,14 @@ export class GuildService { await this.guildLeaderboardService.addLeaderboards(Guild, serializedGuild, "id"); - return deserialize(Guild, flatGuild); + 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( diff --git a/apps/api/src/hypixel/hypixel.service.ts b/apps/api/src/hypixel/hypixel.service.ts index 43092e3cb..73a431a6a 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,16 @@ export class HypixelService { return this.resources.get(resource); } + 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); + + 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..45156f614 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,6 +90,7 @@ 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) { diff --git a/apps/api/src/player/player.service.ts b/apps/api/src/player/player.service.ts index 6bc9d8953..781de88d9 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, @@ -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,18 @@ export class PlayerService { cacheLevel: CacheLevel, selector?: Record ): Promise { + const { player } = await this.getWithFallback(tag, cacheLevel, selector); + return player; + } + + /** + * 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 +89,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 +117,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) { @@ -132,7 +163,7 @@ export class PlayerService { const [status, games] = await Promise.all([ this.hypixelService.getStatus(player.uuid), - this.hypixelService.getRecentGames(player.uuid), + this.hypixelService.getRecentGames(player.uuid).catch(() => []), ]); if (!status) throw new StatusNotFoundException(player); 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..50fc93c18 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,17 @@ 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 { + if (hypixelError) throw hypixelError; oldPlayer = await this.resetPlayer(newPlayer); isNew = true; } @@ -100,7 +106,7 @@ export class SessionService { merged.sessionReset = oldPlayer.sessionReset; merged.isNew = isNew; - return merged; + return { player: merged, hypixelUnavailable: Boolean(hypixelError) }; } public async delete(discordId: string) { diff --git a/locales/en-US/default.json b/locales/en-US/default.json index 642eaad06..c4f679bb1 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -370,6 +370,10 @@ "description": "You need to purchase a higher premium tier to unlock this icon!", "title": "Higher Tier Required" }, + "hypixelUnavailable": { + "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!", "description_name": "A guild by the name of `{{tag}}` could not be found!", 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/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)); }