diff --git a/server/src/core/abstracts/ratelimiter/rate-limiter.abstract.ts b/server/src/core/abstracts/ratelimiter/rate-limiter.abstract.ts index 9c53efe..90eabc8 100644 --- a/server/src/core/abstracts/ratelimiter/rate-limiter.abstract.ts +++ b/server/src/core/abstracts/ratelimiter/rate-limiter.abstract.ts @@ -9,7 +9,7 @@ import logger from "../../../utils/logger"; import type { BaseApi } from "../api/base-api.abstract"; import type { BaseApiOptions } from "../api/base-api.types"; import { ClientAbilities } from "../client/base-client.types"; -import type { RateLimit, RateLimitOptions } from "./rate-limiter.types"; +import type { DailyRateLimit, RateLimit, RateLimitOptions } from "./rate-limiter.types"; const DEFAULT_RATE_LIMIT = { abilities: Object.values(ClientAbilities).filter( @@ -33,12 +33,14 @@ export class ApiRateLimiter { ? Math.floor(data.limit * 0.9) : data.limit, })), - dailyRateLimit: config.DisableDailyRateLimit + dailyRateLimits: config.DisableDailyRateLimit ? undefined - : this._config.dailyRateLimit - && !config.DisableSafeRatelimitMode - ? Math.floor(this._config.dailyRateLimit * 0.9) - : this._config.dailyRateLimit, + : this._config.dailyRateLimits?.map(dailyLimit => ({ + ...dailyLimit, + limit: !config.DisableSafeRatelimitMode + ? Math.floor(dailyLimit.limit * 0.9) + : dailyLimit.limit, + })), }; } @@ -49,21 +51,17 @@ export class ApiRateLimiter { private timeoutIndefiniteMultiplier = 1; private latestTimeoutDate = new Date(); - private readonly redisDailyLimitsKey: string; + private readonly redisDailyLimitsKeyPrefix: string; - private dailyLimit?: { + private dailyLimits = new Map(); constructor(domainHash: string, api: BaseApi, config: RateLimitOptions) { this.api = api; this._config = config; - this.redisDailyLimitsKey = `${RedisKeys.DAILY_RATE_LIMIT}${domainHash}`; - - if (this.config.dailyRateLimit) { - this.dailyLimit = null; - } + this.redisDailyLimitsKeyPrefix = `${RedisKeys.DAILY_RATE_LIMIT}${domainHash}`; if ( !this.config.rateLimits.some(limit => limit.routes.includes("/")) @@ -184,14 +182,32 @@ export class ApiRateLimiter { private async isOnCooldown(route: string) { const limit = this.getRateLimit(route); - const dailyLimit = await this.getDailyRateLimitRemaining(); - if (dailyLimit && dailyLimit.requestsLeft <= 0) { - this.log( - `Tried to make request to ${route} while on daily cooldown. Ignored`, - "warn", - ); - return true; + const checkedLimitKeys = new Set(); + + for (const ability of limit.abilities) { + const applicableLimits = this.findDailyLimitsForAbility(ability); + + for (const dailyLimit of applicableLimits) { + const key = this.getDailyLimitKey(dailyLimit); + + if (checkedLimitKeys.has(key)) + continue; + + checkedLimitKeys.add(key); + + const remaining = await this.getDailyRateLimitRemainingForLimit(dailyLimit); + if (remaining.requestsLeft <= 0) { + const label = dailyLimit.abilities?.length + ? `[${dailyLimit.abilities.map(a => ClientAbilities[a]).join(", ")}]` + : "[global]"; + this.log( + `Tried to make request to ${route} while on daily cooldown ${label}. Ignored`, + "warn", + ); + return true; + } + } } if ( @@ -245,11 +261,37 @@ export class ApiRateLimiter { remaining = this.getRemainingRequests(limit); } + let dailyLimitsLog = ""; + if (this.config.dailyRateLimits?.length) { + const dailyLimitEntries: string[] = []; + const loggedKeys = new Set(); + + for (const ability of limit.abilities) { + const applicableLimits = this.findDailyLimitsForAbility(ability); + for (const dailyLimit of applicableLimits) { + const key = this.getDailyLimitKey(dailyLimit); + if (loggedKeys.has(key)) + continue; + loggedKeys.add(key); + + const cached = this.dailyLimits.get(key); + if (cached) { + const label = dailyLimit.abilities?.length + ? `[${dailyLimit.abilities.map(a => ClientAbilities[a]).join(", ")}]` + : "[global]"; + dailyLimitEntries.push( + `${label}: ${cached.requestsLeft}/${dailyLimit.limit}, refresh at ${new Date(cached.expiresAt).toLocaleString()}`, + ); + } + } + } + if (dailyLimitEntries.length > 0) { + dailyLimitsLog = ` | Daily limits: ${dailyLimitEntries.join("; ")}`; + } + } + const logMessage - = `${this.api.axiosConfig.baseURL}/${route} | Routes: [${limit.routes.join(", ")}] | Remaining requests: ${remaining}/${limit.limit}${ - this.dailyLimit && this.config.dailyRateLimit - ? ` | Remaining daily requests: ${this.dailyLimit.requestsLeft}/${this.config.dailyRateLimit}, refresh at ${new Date(this.dailyLimit.expiresAt).toLocaleString()}` - : ""}`; + = `${this.api.axiosConfig.baseURL}/${route} | Routes: [${limit.routes.join(", ")}] | Remaining requests: ${remaining}/${limit.limit}${dailyLimitsLog}`; this.log(logMessage); @@ -325,26 +367,72 @@ export class ApiRateLimiter { return limit; } - private async getDailyRateLimitRemaining(): Promise<{ + private getDailyLimitKey(dailyLimit: DailyRateLimit): string { + if (!dailyLimit.abilities?.length) { + return "global"; + } + + return dailyLimit.abilities.slice().sort((a, b) => a - b).join(","); + } + + private findDailyLimitsForAbility(ability: ClientAbilities): DailyRateLimit[] { + const dailyLimits = this.config.dailyRateLimits; + if (!dailyLimits?.length) + return []; + + const applicableLimits: DailyRateLimit[] = []; + + for (const limit of dailyLimits) { + if (!limit.abilities?.length) { + applicableLimits.push(limit); + } + else if (limit.abilities.includes(ability)) { + applicableLimits.push(limit); + } + } + + return applicableLimits; + } + + private async getDailyRateLimitRemainingForLimit( + dailyLimit: DailyRateLimit, + retryCount = 0, + ): Promise<{ requestsLeft: number; expiresAt: number; - } | null> { - const isDailyLimitExists = this.config.dailyRateLimit; - if (!isDailyLimitExists) - return null; + }> { + const key = this.getDailyLimitKey(dailyLimit); + const cached = this.dailyLimits.get(key); - if (this.dailyLimit && this.dailyLimit.expiresAt > Date.now()) - return this.dailyLimit; + if (cached && cached.expiresAt > Date.now()) + return cached; + if (cached) { + this.dailyLimits.delete(key); + } + + const redisKey = `${this.redisDailyLimitsKeyPrefix}:${key}`; const result = await this.redis .multi() - .hget(this.redisDailyLimitsKey, "value") - .ttl(this.redisDailyLimitsKey) + .hget(redisKey, "value") + .ttl(redisKey) .exec(); if (!result || result.some(([_, v]) => v === null)) { - await this.updateDailyRateLimitRemaining(0, true); - return await this.getDailyRateLimitRemaining(); + if (retryCount >= 3) { + this.log( + `Failed to initialize daily rate limit for ${key} after ${retryCount} retries. Returning full limit.`, + "error", + ); + const fallbackState = { + requestsLeft: dailyLimit.limit, + expiresAt: Date.now() + 60000, // Cache for 1 minute before retry + }; + this.dailyLimits.set(key, fallbackState); + return fallbackState; + } + await this.updateDailyRateLimitRemainingForLimit(dailyLimit, 0, true); + return await this.getDailyRateLimitRemainingForLimit(dailyLimit, retryCount + 1); } const [[, rawValue], [, ttlSeconds]] = result; @@ -354,41 +442,47 @@ export class ApiRateLimiter { ? 0 : Number(ttlSeconds) * 1000; - this.dailyLimit = { + const limitState = { requestsLeft: value, expiresAt: Date.now() + expiresAt, }; - return this.dailyLimit; + this.dailyLimits.set(key, limitState); + + return limitState; } - private async updateDailyRateLimitRemaining( + private async updateDailyRateLimitRemainingForLimit( + dailyLimit: DailyRateLimit, limitSpent: number, - resetTTL = false, + resetTTL = false, ) { - const dailyLimit = this.config.dailyRateLimit; - if (!dailyLimit) - return null; + const key = this.getDailyLimitKey(dailyLimit); + const redisKey = `${this.redisDailyLimitsKeyPrefix}:${key}`; + const cached = this.dailyLimits.get(key); - let currentDailyLimit = dailyLimit; + let currentDailyLimit = dailyLimit.limit; - if (this.dailyLimit && this.dailyLimit.expiresAt > Date.now()) { - currentDailyLimit = this.dailyLimit.requestsLeft - limitSpent; + if (cached && cached.expiresAt > Date.now()) { + currentDailyLimit = cached.requestsLeft - limitSpent; - this.dailyLimit = { - ...this.dailyLimit, + this.dailyLimits.set(key, { + ...cached, requestsLeft: currentDailyLimit, - }; + }); + } + else if (cached) { + this.dailyLimits.delete(key); } await this.redis.hset( - this.redisDailyLimitsKey, + redisKey, "value", currentDailyLimit, ); if (resetTTL) - await this.redis.expire(this.redisDailyLimitsKey, 86400); // 24 hours + await this.redis.expire(redisKey, 86400); // 24 hours } private getRemainingRequests(limit: RateLimit) { @@ -422,8 +516,20 @@ export class ApiRateLimiter { if (replaceUid) requests.delete(replaceUid); - if (this.config.dailyRateLimit && replaceUid == null) { - this.updateDailyRateLimitRemaining(1); + if (this.config.dailyRateLimits?.length && replaceUid == null) { + const decrementedKeys = new Set(); + + for (const ability of limit.abilities) { + const applicableLimits = this.findDailyLimitsForAbility(ability); + + for (const dailyLimit of applicableLimits) { + const key = this.getDailyLimitKey(dailyLimit); + if (!decrementedKeys.has(key)) { + decrementedKeys.add(key); + this.updateDailyRateLimitRemainingForLimit(dailyLimit, 1); + } + } + } } const uid = crypto.randomUUID(); diff --git a/server/src/core/abstracts/ratelimiter/rate-limiter.types.ts b/server/src/core/abstracts/ratelimiter/rate-limiter.types.ts index c19b16c..9359708 100644 --- a/server/src/core/abstracts/ratelimiter/rate-limiter.types.ts +++ b/server/src/core/abstracts/ratelimiter/rate-limiter.types.ts @@ -6,11 +6,16 @@ export type RateLimitOptions = { limit?: string; reset?: string; }; - dailyRateLimit?: number; + dailyRateLimits?: DailyRateLimit[]; rateLimits: RateLimit[]; onCooldownUntil?: number; // Active only if we got 429 status code before }; +export type DailyRateLimit = { + limit: number; + abilities?: ClientAbilities[]; // If undefined, applies to all abilities +}; + export type RateLimit = { abilities: ClientAbilities[]; routes: string[]; // ! Make sure this matches the "defaultUrl + route + value" logic diff --git a/server/src/core/domains/beatmaps.download/osulabs.client.ts b/server/src/core/domains/beatmaps.download/osulabs.client.ts index b96d011..e50c061 100644 --- a/server/src/core/domains/beatmaps.download/osulabs.client.ts +++ b/server/src/core/domains/beatmaps.download/osulabs.client.ts @@ -28,7 +28,7 @@ export class OsulabsClient extends BaseClient { ], }, { - dailyRateLimit: 10000, + dailyRateLimits: [{ limit: 10000 }], headers: { remaining: "x-ratelimit-remaining", }, diff --git a/server/src/core/domains/catboy.best/mino.client.ts b/server/src/core/domains/catboy.best/mino.client.ts index f30230b..cb0a35b 100644 --- a/server/src/core/domains/catboy.best/mino.client.ts +++ b/server/src/core/domains/catboy.best/mino.client.ts @@ -33,7 +33,18 @@ export class MinoClient extends BaseClient { ], }, { - dailyRateLimit: 10000, + dailyRateLimits: [ + { limit: 10000 }, + { + limit: 2000, + // Mino has nested daily rate limits for downloads + abilities: [ + ClientAbilities.DownloadBeatmapSetById, + ClientAbilities.DownloadBeatmapSetByIdNoVideo, + ClientAbilities.DownloadOsuBeatmap, + ], + }, + ], headers: { remaining: "x-ratelimit-remaining", }, diff --git a/server/tests/mirrors.manager.test.ts b/server/tests/mirrors.manager.test.ts index 280431b..948b209 100644 --- a/server/tests/mirrors.manager.test.ts +++ b/server/tests/mirrors.manager.test.ts @@ -1634,26 +1634,27 @@ describe("MirrorsManager", () => { } }); - test("DisableDailyRateLimit is set to true, daily rate limit should be undefined", async () => { + test("DisableDailyRateLimit is set to true, daily rate limits should be undefined", async () => { config.DisableDailyRateLimit = true; const minoClient = getMirrorClient(MinoClient); // @ts-expect-error skip type check due to protected property - const { dailyRateLimit } = minoClient.api.config; + const { dailyRateLimits } = minoClient.api.config; - expect(dailyRateLimit).toBeUndefined(); + expect(dailyRateLimits).toBeUndefined(); }); - test("DisableDailyRateLimit is set to false, daily rate limit should be defined", async () => { + test("DisableDailyRateLimit is set to false, daily rate limits should be defined", async () => { config.DisableDailyRateLimit = false; const minoClient = getMirrorClient(MinoClient); // @ts-expect-error skip type check due to protected property - const { dailyRateLimit } = minoClient.api.config; + const { dailyRateLimits } = minoClient.api.config; - expect(dailyRateLimit).toBeDefined(); + expect(dailyRateLimits).toBeDefined(); + expect(dailyRateLimits?.length).toBeGreaterThan(0); }); test("DisableSafeRatelimitMode is set to true, should complete 100% of the requests", async () => { @@ -1851,4 +1852,371 @@ describe("MirrorsManager", () => { }, ); }); + + describe("Daily rate limits with abilities", () => { + test("Global daily rate limit (no abilities) should apply to all abilities", async () => { + config.DisableDailyRateLimit = false; + + const minoClient = getMirrorClient(MinoClient); + + // @ts-expect-error skip type check due to protected property + const { dailyRateLimits } = minoClient.api.config; + + expect(dailyRateLimits).toBeDefined(); + expect(dailyRateLimits?.length).toBeGreaterThanOrEqual(1); + + // Check that at least one limit is a global limit (no abilities) + const globalLimit = dailyRateLimits?.find( + (limit: any) => !limit.abilities?.length, + ); + expect(globalLimit).toBeDefined(); + }); + + test("Multiple daily rate limits with different abilities should be tracked separately", async () => { + config.DisableDailyRateLimit = false; + config.DisableSafeRatelimitMode = true; + + // Create a custom client configuration with ability-specific daily limits + const TestableMinoClient = class extends MinoClient { + constructor(storageManager: any) { + super(storageManager); + // @ts-expect-error accessing protected property for testing + this.api._config.dailyRateLimits = [ + { limit: 100, abilities: [ClientAbilities.DownloadBeatmapSetById, ClientAbilities.DownloadBeatmapSetByIdNoVideo] }, + { limit: 200, abilities: [ClientAbilities.GetBeatmapById, ClientAbilities.GetBeatmapByHash] }, + { limit: 50 }, // Global fallback for other abilities + ]; + } + }; + + config.MirrorsToIgnore = mirrors + .filter(m => m !== MinoClient) + .map(m => m.name.slice(0, -6).toLowerCase()); + + mirrorsManager = new MirrorsManager(mockStorageManager); + + // Override the client with our testable version + // @ts-expect-error accessing protected property for testing + mirrorsManager.clients[0].client = new TestableMinoClient(mockStorageManager); + + // @ts-expect-error accessing protected property for testing + const testClient = mirrorsManager.clients[0].client; + + // @ts-expect-error skip type check due to protected property + const { dailyRateLimits } = testClient.api.config; + + expect(dailyRateLimits).toBeDefined(); + expect(dailyRateLimits?.length).toBe(3); + + // Check that the first limit applies to download abilities + const downloadLimit = dailyRateLimits?.find( + (limit: any) => limit.abilities?.includes(ClientAbilities.DownloadBeatmapSetById), + ); + expect(downloadLimit).toBeDefined(); + expect(downloadLimit?.limit).toBe(100); + + // Check that the second limit applies to get beatmap abilities + const getBeatmapLimit = dailyRateLimits?.find( + (limit: any) => limit.abilities?.includes(ClientAbilities.GetBeatmapById), + ); + expect(getBeatmapLimit).toBeDefined(); + expect(getBeatmapLimit?.limit).toBe(200); + + // Check that the global limit exists (no abilities) + const globalLimit = dailyRateLimits?.find( + (limit: any) => !limit.abilities?.length, + ); + expect(globalLimit).toBeDefined(); + expect(globalLimit?.limit).toBe(50); + }); + + test("Ability-specific daily limit should block only that ability when exhausted", async () => { + config.DisableDailyRateLimit = false; + config.DisableSafeRatelimitMode = true; + + // Create a custom client with very low limit for download + const TestableMinoClient = class extends MinoClient { + constructor(storageManager: any) { + super(storageManager); + // @ts-expect-error accessing protected property for testing + this.api._config.dailyRateLimits = [ + { limit: 1, abilities: [ClientAbilities.DownloadOsuBeatmap] }, + { limit: 1000 }, // High global limit for other abilities + ]; + } + }; + + config.MirrorsToIgnore = mirrors + .filter(m => m !== MinoClient) + .map(m => m.name.slice(0, -6).toLowerCase()); + + mirrorsManager = new MirrorsManager(mockStorageManager); + + // @ts-expect-error accessing protected property for testing + mirrorsManager.clients[0].client = new TestableMinoClient(mockStorageManager); + + // @ts-expect-error accessing protected property for testing + const testClient = mirrorsManager.clients[0].client; + + const { mockArrayBuffer } = Mocker.getClientMockMethods(testClient); + const { generateBeatmap } = Mocker.getClientGenerateMethods(testClient); + + // First download should succeed + mockArrayBuffer(); + const downloadResult1 = await mirrorsManager.downloadOsuBeatmap({ + beatmapId: 1, + }); + expect(downloadResult1.status).toBe(200); + + // Second download should fail due to daily limit + mockArrayBuffer(); + const downloadResult2 = await mirrorsManager.downloadOsuBeatmap({ + beatmapId: 2, + }); + expect(downloadResult2.status).toBe(502); + + // But getBeatmap should still work (different daily limit) + // Use generateBeatmap with set: null to avoid nested beatmapset conversion issues + Mocker.mockRequest(testClient, "baseApi", "get", { + data: generateBeatmap({ id: 100, set: null }), + status: 200, + headers: {}, + }); + const beatmapResult = await mirrorsManager.getBeatmap({ + beatmapId: 100, + }); + expect(beatmapResult.status).toBe(200); + }); + + test("Global daily limit should apply when no ability-specific limit matches", async () => { + config.DisableDailyRateLimit = false; + config.DisableSafeRatelimitMode = true; + + // Create a custom client with only ability-specific limits (no global) + const TestableMinoClient = class extends MinoClient { + constructor(storageManager: any) { + super(storageManager); + // @ts-expect-error accessing protected property for testing + this.api._config.dailyRateLimits = [ + { limit: 1, abilities: [ClientAbilities.DownloadOsuBeatmap] }, + { limit: 1000 }, // Global fallback + ]; + } + }; + + config.MirrorsToIgnore = mirrors + .filter(m => m !== MinoClient) + .map(m => m.name.slice(0, -6).toLowerCase()); + + mirrorsManager = new MirrorsManager(mockStorageManager); + + // @ts-expect-error accessing protected property for testing + mirrorsManager.clients[0].client = new TestableMinoClient(mockStorageManager); + + // @ts-expect-error accessing protected property for testing + const testClient = mirrorsManager.clients[0].client; + + const { generateBeatmap } = Mocker.getClientGenerateMethods(testClient); + + // GetBeatmap uses global limit (1000), should work many times + // Start from 1 to avoid falsy beatmapId + // Use set: null to avoid nested beatmapset conversion issues + for (let i = 1; i <= 5; i++) { + Mocker.mockRequest(testClient, "baseApi", "get", { + data: generateBeatmap({ id: i, set: null }), + status: 200, + headers: {}, + }); + const result = await mirrorsManager.getBeatmap({ + beatmapId: i, + }); + expect(result.status).toBe(200); + } + }); + + test("Both specific AND global limits should be decremented when ability has both", async () => { + config.DisableDailyRateLimit = false; + config.DisableSafeRatelimitMode = true; + + // Create a client with BOTH a specific limit for DownloadOsuBeatmap AND a global limit + // Both should be decremented when using DownloadOsuBeatmap + const TestableMinoClient = class extends MinoClient { + constructor(storageManager: any) { + super(storageManager); + // @ts-expect-error accessing protected property for testing + this.api._config.dailyRateLimits = [ + { limit: 5, abilities: [ClientAbilities.DownloadOsuBeatmap] }, // Specific: 5 requests + { limit: 3 }, // Global: 3 requests - this is the bottleneck + ]; + // Pre-initialize the in-memory cache to avoid Redis state pollution from other tests + // @ts-expect-error accessing private property for testing + this.api.dailyLimits.set(`${ClientAbilities.DownloadOsuBeatmap}`, { + requestsLeft: 5, + expiresAt: Date.now() + 86400000, + }); + // @ts-expect-error accessing private property for testing + this.api.dailyLimits.set("global", { + requestsLeft: 3, + expiresAt: Date.now() + 86400000, + }); + } + }; + + config.MirrorsToIgnore = mirrors + .filter(m => m !== MinoClient) + .map(m => m.name.slice(0, -6).toLowerCase()); + + mirrorsManager = new MirrorsManager(mockStorageManager); + + // @ts-expect-error accessing protected property for testing + mirrorsManager.clients[0].client = new TestableMinoClient(mockStorageManager); + + // @ts-expect-error accessing protected property for testing + const testClient = mirrorsManager.clients[0].client; + + const { mockArrayBuffer } = Mocker.getClientMockMethods(testClient); + + // Should be able to do 3 requests (limited by global limit of 3) + for (let i = 1; i <= 3; i++) { + mockArrayBuffer(); + const result = await mirrorsManager.downloadOsuBeatmap({ + beatmapId: i, + }); + expect(result.status).toBe(200); + } + + // 4th request should fail because global limit (3) is exhausted + // even though specific limit (5) still has capacity + mockArrayBuffer(); + const result4 = await mirrorsManager.downloadOsuBeatmap({ + beatmapId: 4, + }); + expect(result4.status).toBe(502); + }); + + test("Either global OR specific limit exhaustion should block the ability", async () => { + config.DisableDailyRateLimit = false; + config.DisableSafeRatelimitMode = true; + + // Create a client where specific limit is lower than global + const TestableMinoClient = class extends MinoClient { + constructor(storageManager: any) { + super(storageManager); + // @ts-expect-error accessing protected property for testing + this.api._config.dailyRateLimits = [ + { limit: 2, abilities: [ClientAbilities.DownloadOsuBeatmap] }, // Specific: 2 requests - bottleneck + { limit: 100 }, // Global: plenty of capacity + ]; + // Pre-initialize the in-memory cache to avoid Redis state pollution from other tests + // @ts-expect-error accessing private property for testing + this.api.dailyLimits.set(`${ClientAbilities.DownloadOsuBeatmap}`, { + requestsLeft: 2, + expiresAt: Date.now() + 86400000, + }); + // @ts-expect-error accessing private property for testing + this.api.dailyLimits.set("global", { + requestsLeft: 100, + expiresAt: Date.now() + 86400000, + }); + } + }; + + config.MirrorsToIgnore = mirrors + .filter(m => m !== MinoClient) + .map(m => m.name.slice(0, -6).toLowerCase()); + + mirrorsManager = new MirrorsManager(mockStorageManager); + + // @ts-expect-error accessing protected property for testing + mirrorsManager.clients[0].client = new TestableMinoClient(mockStorageManager); + + // @ts-expect-error accessing protected property for testing + const testClient = mirrorsManager.clients[0].client; + + const { mockArrayBuffer } = Mocker.getClientMockMethods(testClient); + + // Should be able to do 2 requests (limited by specific limit of 2) + for (let i = 1; i <= 2; i++) { + mockArrayBuffer(); + const result = await mirrorsManager.downloadOsuBeatmap({ + beatmapId: i, + }); + expect(result.status).toBe(200); + } + + // 3rd request should fail because specific limit (2) is exhausted + // even though global limit (100) still has capacity + mockArrayBuffer(); + const result3 = await mirrorsManager.downloadOsuBeatmap({ + beatmapId: 3, + }); + expect(result3.status).toBe(502); + }); + + test("Non-applicable daily limits should NOT be decremented for unrelated abilities", async () => { + config.DisableDailyRateLimit = false; + config.DisableSafeRatelimitMode = true; + + // Create a client with a specific limit for download abilities AND a global limit + const TestableMinoClient = class extends MinoClient { + constructor(storageManager: any) { + super(storageManager); + // @ts-expect-error accessing protected property for testing + this.api._config.dailyRateLimits = [ + { limit: 100, abilities: [ClientAbilities.DownloadOsuBeatmap, ClientAbilities.DownloadBeatmapSetById] }, // Download-specific + { limit: 1000 }, // Global + ]; + // Pre-initialize the in-memory cache + // @ts-expect-error accessing private property for testing + this.api.dailyLimits.set(`${ClientAbilities.DownloadOsuBeatmap},${ClientAbilities.DownloadBeatmapSetById}`.split(",").sort((a, b) => Number(a) - Number(b)).join(","), { + requestsLeft: 100, + expiresAt: Date.now() + 86400000, + }); + // @ts-expect-error accessing private property for testing + this.api.dailyLimits.set("global", { + requestsLeft: 1000, + expiresAt: Date.now() + 86400000, + }); + } + }; + + config.MirrorsToIgnore = mirrors + .filter(m => m !== MinoClient) + .map(m => m.name.slice(0, -6).toLowerCase()); + + mirrorsManager = new MirrorsManager(mockStorageManager); + + // @ts-expect-error accessing protected property for testing + mirrorsManager.clients[0].client = new TestableMinoClient(mockStorageManager); + + // @ts-expect-error accessing protected property for testing + const testClient = mirrorsManager.clients[0].client; + + const { generateBeatmap } = Mocker.getClientGenerateMethods(testClient); + + // Make GetBeatmap requests (non-download ability) + for (let i = 1; i <= 5; i++) { + Mocker.mockRequest(testClient, "baseApi", "get", { + data: generateBeatmap({ id: i, set: null }), + status: 200, + headers: {}, + }); + const result = await mirrorsManager.getBeatmap({ + beatmapId: i, + }); + expect(result.status).toBe(200); + } + + // Verify the download-specific limit was NOT decremented (should still be 100) + const downloadLimitKey = `${ClientAbilities.DownloadOsuBeatmap},${ClientAbilities.DownloadBeatmapSetById}`.split(",").sort((a, b) => Number(a) - Number(b)).join(","); + // @ts-expect-error accessing private property for testing + const downloadLimit = testClient.api.dailyLimits.get(downloadLimitKey); + expect(downloadLimit?.requestsLeft).toBe(100); // Unchanged! + + // Verify the global limit WAS decremented (should be 1000 - 5 = 995) + // @ts-expect-error accessing private property for testing + const globalLimit = testClient.api.dailyLimits.get("global"); + expect(globalLimit?.requestsLeft).toBe(995); // Decremented by 5 + }); + }); });