diff --git a/packages/backend/eslint.config.js b/packages/backend/eslint.config.js index 452045bc3e..7ee9953478 100644 --- a/packages/backend/eslint.config.js +++ b/packages/backend/eslint.config.js @@ -42,6 +42,13 @@ export default [ name: '__filename', message: 'Not in ESModule. Use `import.meta.url` instead.', }], + // https://typescript-eslint.io/rules/prefer-nullish-coalescing/ + '@typescript-eslint/prefer-nullish-coalescing': ['warn', { + ignorePrimitives: { + // Without this, the rule breaks for nullable booleans + boolean: true, + }, + }], }, }, { diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index c083068392..b18db7f366 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -14,6 +14,8 @@ import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationSe import { SystemWebhookService } from '@/core/SystemWebhookService.js'; import { UserSearchService } from '@/core/UserSearchService.js'; import { WebhookTestService } from '@/core/WebhookTestService.js'; +import { TimeService } from '@/core/TimeService.js'; +import { EnvService } from '@/core/EnvService.js'; import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AnnouncementService } from './AnnouncementService.js'; @@ -381,6 +383,8 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp ChannelFollowingService, RegistryApiService, ReversiService, + TimeService, + EnvService, ChartLoggerService, FederationChart, @@ -680,6 +684,8 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp ChannelFollowingService, RegistryApiService, ReversiService, + TimeService, + EnvService, FederationChart, NotesChart, diff --git a/packages/backend/src/core/EnvService.ts b/packages/backend/src/core/EnvService.ts new file mode 100644 index 0000000000..8cc3b95735 --- /dev/null +++ b/packages/backend/src/core/EnvService.ts @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; + +/** + * Provides access to the process environment variables. + * This exists for testing purposes, so that a test can mock the environment without corrupting state for other tests. + */ +@Injectable() +export class EnvService { + /** + * Passthrough to process.env + */ + public get env() { + return process.env; + } +} diff --git a/packages/backend/src/core/TimeService.ts b/packages/backend/src/core/TimeService.ts new file mode 100644 index 0000000000..59c3d4c12b --- /dev/null +++ b/packages/backend/src/core/TimeService.ts @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; + +/** + * Provides abstractions to access the current time. + * Exists for unit testing purposes, so that tests can "simulate" any given time for consistency. + */ +@Injectable() +export class TimeService { + /** + * Returns Date.now() + */ + public get now() { + return Date.now(); + } + + /** + * Returns a new Date instance. + */ + public get date() { + return new Date(); + } +} diff --git a/packages/backend/src/misc/rate-limit-utils.ts b/packages/backend/src/misc/rate-limit-utils.ts new file mode 100644 index 0000000000..9909bb97fa --- /dev/null +++ b/packages/backend/src/misc/rate-limit-utils.ts @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { FastifyReply } from 'fastify'; + +export type RateLimit = BucketRateLimit | LegacyRateLimit; +export type Keyed = T & { key: string }; + +/** + * Rate limit based on "leaky bucket" logic. + * The bucket count increases with each call, and decreases gradually at a given rate. + * The subject is blocked until the bucket count drops below the limit. + */ +export interface BucketRateLimit { + /** + * Unique key identifying the particular resource (or resource group) being limited. + */ + key?: string; + + /** + * Constant value identifying the type of rate limit. + */ + type: 'bucket'; + + /** + * Size of the bucket, in number of requests. + * The subject will be blocked when the number of calls exceeds this size. + */ + size: number; + + /** + * How often the bucket should "drip" and reduce the counter, measured in milliseconds. + * Defaults to 1000 (1 second). + */ + dripRate?: number; + + /** + * Amount to reduce the counter on each drip. + * Defaults to 1. + */ + dripSize?: number; +} + +/** + * Legacy rate limit based on a "request window" with a maximum number of requests within a given time box. + * These will be translated into a bucket with linear drip rate. + */ +export interface LegacyRateLimit { + /** + * Unique key identifying the particular resource (or resource group) being limited. + */ + key?: string; + + /** + * Constant value identifying the type of rate limit. + * Must be excluded or explicitly set to undefined + */ + type?: undefined; + + /** + * Duration of the request window, in milliseconds. + * If present, then "max" must also be included. + */ + duration?: number; + + /** + * Maximum number of requests allowed in the request window. + * If present, then "duration" must also be included. + */ + max?: number; + + /** + * Optional minimum interval between consecutive requests. + * Will apply in addition to the primary rate limit. + */ + minInterval?: number; +} + +/** + * Metadata about the current status of a rate limiter + */ +export interface LimitInfo { + /** + * True if the limit has been reached, and the call should be blocked. + */ + blocked: boolean; + + /** + * Number of calls that can be made before the limit is triggered. + */ + remaining: number; + + /** + * Time in seconds until the next call can be made, or zero if the next call can be made immediately. + * Rounded up to the nearest second. + */ + resetSec: number; + + /** + * Time in milliseconds until the next call can be made, or zero if the next call can be made immediately. + * Rounded up to the nearest milliseconds. + */ + resetMs: number; + + /** + * Time in seconds until the limit has fully reset. + * Rounded up to the nearest second. + */ + fullResetSec: number; + + /** + * Time in milliseconds until the limit has fully reset. + * Rounded up to the nearest millisecond. + */ + fullResetMs: number; +} + +export function isLegacyRateLimit(limit: RateLimit): limit is LegacyRateLimit { + return limit.type === undefined; +} + +export function hasMinLimit(limit: LegacyRateLimit): limit is LegacyRateLimit & { minInterval: number } { + return !!limit.minInterval; +} + +export function sendRateLimitHeaders(reply: FastifyReply, info: LimitInfo): void { + // Number of seconds until the limit has fully reset. + const clear = (info.fullResetMs / 1000).toFixed(3); + reply.header('X-RateLimit-Clear', clear); + + // Number of calls that can be made before being limited. + const remaining = info.remaining.toString(); + reply.header('X-RateLimit-Remaining', remaining); + + if (info.blocked) { + // Number of seconds to wait before trying again. Left for backwards compatibility. + const retry = info.resetSec.toString(); + reply.header('Retry-After', retry); + + // Number of milliseconds to wait before trying again. + const reset = (info.resetMs / 1000).toFixed(3); + reply.header('X-RateLimit-Reset', reset); + } +} diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 18d313db06..5293d529ad 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -28,12 +28,12 @@ import { bindThis } from '@/decorators.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; import { correctFilename } from '@/misc/correct-filename.js'; import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js'; -import { RateLimiterService } from '@/server/api/RateLimiterService.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; import { AuthenticateService } from '@/server/api/AuthenticateService.js'; -import type { IEndpointMeta } from '@/server/api/endpoints.js'; +import { RoleService } from '@/core/RoleService.js'; +import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; +import { Keyed, RateLimit, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; -import type Limiter from 'ratelimiter'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -58,7 +58,8 @@ export class FileServerService { private internalStorageService: InternalStorageService, private loggerService: LoggerService, private authenticateService: AuthenticateService, - private rateLimiterService: RateLimiterService, + private rateLimiterService: SkRateLimiterService, + private roleService: RoleService, ) { this.logger = this.loggerService.getLogger('server', 'gray'); @@ -625,48 +626,44 @@ export class FileServerService { // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. const [user] = await this.authenticateService.authenticate(token); const actor = user?.id ?? getIpHash(request.ip); + const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1; // Call both limits: the per-resource limit and the shared cross-resource limit - return await this.checkResourceLimit(reply, actor, group, resource) && await this.checkSharedLimit(reply, actor, group); + return await this.checkResourceLimit(reply, actor, group, resource, factor) && await this.checkSharedLimit(reply, actor, group, factor); } - private async checkResourceLimit(reply: FastifyReply, actor: string, group: string, resource: string): Promise { - const limit = { + private async checkResourceLimit(reply: FastifyReply, actor: string, group: string, resource: string, factor = 1): Promise { + const limit: Keyed = { // Group by resource key: `${group}${resource}`, + type: 'bucket', - // Maximum of 10 requests / 10 minutes - max: 10, - duration: 1000 * 60 * 10, + // Maximum of 10 requests, average rate of 1 per minute + size: 10, + dripRate: 1000 * 60, }; - return await this.checkLimit(reply, actor, limit); + return await this.checkLimit(reply, actor, limit, factor); } - private async checkSharedLimit(reply: FastifyReply, actor: string, group: string): Promise { - const limit = { + private async checkSharedLimit(reply: FastifyReply, actor: string, group: string, factor = 1): Promise { + const limit: Keyed = { key: group, + type: 'bucket', - // Maximum of 3600 requests per hour, which is an average of 1 per second. - max: 3600, - duration: 1000 * 60 * 60, + // Maximum of 3600 requests, average rate of 1 per second. + size: 3600, }; - return await this.checkLimit(reply, actor, limit); + return await this.checkLimit(reply, actor, limit, factor); } - private async checkLimit(reply: FastifyReply, actor: string, limit: IEndpointMeta['limit'] & { key: NonNullable }): Promise { - try { - await this.rateLimiterService.limit(limit, actor); - return true; - } catch (err) { - // errはLimiter.LimiterInfoであることが期待される - if (hasRateLimitInfo(err)) { - const cooldownInSeconds = Math.ceil((err.info.resetMs - Date.now()) / 1000); - // もしかするとマイナスになる可能性がなくはないのでマイナスだったら0にしておく - reply.header('Retry-After', Math.max(cooldownInSeconds, 0).toString(10)); - } + private async checkLimit(reply: FastifyReply, actor: string, limit: Keyed, factor = 1): Promise { + const info = await this.rateLimiterService.limit(limit, actor, factor); + sendRateLimitHeaders(reply, info); + + if (info.blocked) { reply.code(429); reply.send({ message: 'Rate limit exceeded. Please try again later.', @@ -676,9 +673,8 @@ export class FileServerService { return false; } + + return true; } } -function hasRateLimitInfo(err: unknown): err is { info: Limiter.LimiterInfo } { - return err != null && typeof(err) === 'object' && 'info' in err; -} diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 216e6b4fb8..c1d7c088f1 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -6,6 +6,7 @@ import { Module } from '@nestjs/common'; import { EndpointsModule } from '@/server/api/EndpointsModule.js'; import { CoreModule } from '@/core/CoreModule.js'; +import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; import { ApiCallService } from './api/ApiCallService.js'; import { FileServerService } from './FileServerService.js'; import { HealthServerService } from './HealthServerService.js'; @@ -73,6 +74,8 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j ApiLoggerService, ApiServerService, AuthenticateService, + SkRateLimiterService, + // No longer used, but kept for backwards compatibility RateLimiterService, SigninApiService, SigninWithPasskeyApiService, diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 6f51825494..c6c33f7303 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -18,8 +18,9 @@ import { createTemp } from '@/misc/create-temp.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type { Config } from '@/config.js'; +import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; +import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; import { ApiError } from './error.js'; -import { RateLimiterService } from './RateLimiterService.js'; import { ApiLoggerService } from './ApiLoggerService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; @@ -49,7 +50,7 @@ export class ApiCallService implements OnApplicationShutdown { private userIpsRepository: UserIpsRepository, private authenticateService: AuthenticateService, - private rateLimiterService: RateLimiterService, + private rateLimiterService: SkRateLimiterService, private roleService: RoleService, private apiLoggerService: ApiLoggerService, ) { @@ -65,16 +66,6 @@ export class ApiCallService implements OnApplicationShutdown { let statusCode = err.httpStatusCode; if (err.httpStatusCode === 401) { reply.header('WWW-Authenticate', 'Bearer realm="Misskey"'); - } else if (err.code === 'RATE_LIMIT_EXCEEDED') { - const info: unknown = err.info; - const unixEpochInSeconds = Date.now(); - if (typeof(info) === 'object' && info && 'resetMs' in info && typeof(info.resetMs) === 'number') { - const cooldownInSeconds = Math.ceil((info.resetMs - unixEpochInSeconds) / 1000); - // もしかするとマイナスになる可能性がなくはないのでマイナスだったら0にしておく - reply.header('Retry-After', Math.max(cooldownInSeconds, 0).toString(10)); - } else { - this.logger.warn(`rate limit information has unexpected type ${typeof(err.info?.reset)}`); - } } else if (err.kind === 'client') { reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_request", error_description="${err.message}"`); statusCode = statusCode ?? 400; @@ -168,7 +159,7 @@ export class ApiCallService implements OnApplicationShutdown { return; } this.authenticateService.authenticate(token).then(([user, app]) => { - this.call(endpoint, user, app, body, null, request).then((res) => { + this.call(endpoint, user, app, body, null, request, reply).then((res) => { if (request.method === 'GET' && endpoint.meta.cacheSec && !token && !user) { reply.header('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`); } @@ -229,7 +220,7 @@ export class ApiCallService implements OnApplicationShutdown { this.call(endpoint, user, app, fields, { name: multipartData.filename, path: path, - }, request).then((res) => { + }, request, reply).then((res) => { this.send(reply, res); }).catch((err: ApiError) => { this.#sendApiError(reply, err); @@ -304,6 +295,7 @@ export class ApiCallService implements OnApplicationShutdown { path: string; } | null, request: FastifyRequest<{ Body: Record | undefined, Querystring: Record }>, + reply: FastifyReply, ) { const isSecure = user != null && token == null; @@ -312,7 +304,7 @@ export class ApiCallService implements OnApplicationShutdown { } // For endpoints without a limit, the default is 10 calls per second - const endpointLimit: IEndpointMeta['limit'] = ep.meta.limit ?? { + const endpointLimit = ep.meta.limit ?? { duration: 1000, max: 10, }; @@ -328,30 +320,28 @@ export class ApiCallService implements OnApplicationShutdown { limitActor = getIpHash(request.ip); } - const limit = Object.assign({}, endpointLimit); - - if (limit.key == null) { - (limit as any).key = ep.name; - } - // TODO: 毎リクエスト計算するのもあれだしキャッシュしたい const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1; if (factor > 0) { + const limit = { + key: ep.name, + ...endpointLimit, + }; + // Rate limit - await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable }, limitActor, factor).catch(err => { - if ('info' in err) { - // errはLimiter.LimiterInfoであることが期待される - throw new ApiError({ - message: 'Rate limit exceeded. Please try again later.', - code: 'RATE_LIMIT_EXCEEDED', - id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', - httpStatusCode: 429, - }, err.info); - } else { - throw new TypeError('information must be a rate-limiter information.'); - } - }); + const info = await this.rateLimiterService.limit(limit, limitActor, factor); + + sendRateLimitHeaders(reply, info); + + if (info.blocked) { + throw new ApiError({ + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', + httpStatusCode: 429, + }); + } } } diff --git a/packages/backend/src/server/api/RateLimiterService.ts b/packages/backend/src/server/api/RateLimiterService.ts index e9afb9d05a..879529090f 100644 --- a/packages/backend/src/server/api/RateLimiterService.ts +++ b/packages/backend/src/server/api/RateLimiterService.ts @@ -10,8 +10,10 @@ import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; +import { LegacyRateLimit } from '@/misc/rate-limit-utils.js'; import type { IEndpointMeta } from './endpoints.js'; +/** @deprecated Use SkRateLimiterService instead */ @Injectable() export class RateLimiterService { private logger: Logger; @@ -31,7 +33,7 @@ export class RateLimiterService { } @bindThis - public limit(limitation: IEndpointMeta['limit'] & { key: NonNullable }, actor: string, factor = 1) { + public limit(limitation: LegacyRateLimit & { key: NonNullable }, actor: string, factor = 1) { return new Promise((ok, reject) => { if (this.disabled) ok(); diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index 64af7da7a6..1a4ce0a54c 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -21,12 +21,13 @@ import { IdService } from '@/core/IdService.js'; import { bindThis } from '@/decorators.js'; import { WebAuthnService } from '@/core/WebAuthnService.js'; import { UserAuthService } from '@/core/UserAuthService.js'; -import { RateLimiterService } from './RateLimiterService.js'; +import { isSystemAccount } from '@/misc/is-system-account.js'; +import type { MiMeta } from '@/models/_.js'; +import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; +import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; import { SigninService } from './SigninService.js'; import type { AuthenticationResponseJSON } from '@simplewebauthn/types'; import type { FastifyReply, FastifyRequest } from 'fastify'; -import { isSystemAccount } from '@/misc/is-system-account.js'; -import type { MiMeta } from '@/models/_.js'; @Injectable() export class SigninApiService { @@ -47,7 +48,7 @@ export class SigninApiService { private signinsRepository: SigninsRepository, private idService: IdService, - private rateLimiterService: RateLimiterService, + private rateLimiterService: SkRateLimiterService, private signinService: SigninService, private userAuthService: UserAuthService, private webAuthnService: WebAuthnService, @@ -79,10 +80,12 @@ export class SigninApiService { return { error }; } - try { // not more than 1 attempt per second and not more than 10 attempts per hour - await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip)); - } catch (err) { + const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip)); + + sendRateLimitHeaders(reply, rateLimit); + + if (rateLimit.blocked) { reply.code(429); return { error: { diff --git a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts index 9ba23c54e2..e94d2b6b68 100644 --- a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts +++ b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts @@ -21,7 +21,8 @@ import { WebAuthnService } from '@/core/WebAuthnService.js'; import Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; import type { IdentifiableError } from '@/misc/identifiable-error.js'; -import { RateLimiterService } from './RateLimiterService.js'; +import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; +import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; import { SigninService } from './SigninService.js'; import type { AuthenticationResponseJSON } from '@simplewebauthn/types'; import type { FastifyReply, FastifyRequest } from 'fastify'; @@ -43,7 +44,7 @@ export class SigninWithPasskeyApiService { private signinsRepository: SigninsRepository, private idService: IdService, - private rateLimiterService: RateLimiterService, + private rateLimiterService: SkRateLimiterService, private signinService: SigninService, private webAuthnService: WebAuthnService, private loggerService: LoggerService, @@ -84,11 +85,13 @@ export class SigninWithPasskeyApiService { return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' }); }; - try { - // Not more than 1 API call per 250ms and not more than 100 attempts per 30min - // NOTE: 1 Sign-in require 2 API calls - await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip)); - } catch (err) { + // Not more than 1 API call per 250ms and not more than 100 attempts per 30min + // NOTE: 1 Sign-in require 2 API calls + const rateLimit = await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip)); + + sendRateLimitHeaders(reply, rateLimit); + + if (rateLimit.blocked) { reply.code(429); return { error: { diff --git a/packages/backend/src/server/api/SkRateLimiterService.ts b/packages/backend/src/server/api/SkRateLimiterService.ts new file mode 100644 index 0000000000..6415ee905c --- /dev/null +++ b/packages/backend/src/server/api/SkRateLimiterService.ts @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { LoggerService } from '@/core/LoggerService.js'; +import { TimeService } from '@/core/TimeService.js'; +import { EnvService } from '@/core/EnvService.js'; +import { DI } from '@/di-symbols.js'; +import type Logger from '@/logger.js'; +import { BucketRateLimit, LegacyRateLimit, LimitInfo, RateLimit, hasMinLimit, isLegacyRateLimit, Keyed } from '@/misc/rate-limit-utils.js'; + +@Injectable() +export class SkRateLimiterService { + private readonly logger: Logger; + private readonly disabled: boolean; + + constructor( + @Inject(TimeService) + private readonly timeService: TimeService, + + @Inject(DI.redis) + private readonly redisClient: Redis.Redis, + + @Inject(LoggerService) + loggerService: LoggerService, + + @Inject(EnvService) + envService: EnvService, + ) { + this.logger = loggerService.getLogger('limiter'); + this.disabled = envService.env.NODE_ENV !== 'production'; // TODO disable in TEST *only* + } + + public async limit(limit: Keyed, actor: string, factor = 1): Promise { + if (this.disabled || factor === 0) { + return { + blocked: false, + remaining: Number.MAX_SAFE_INTEGER, + resetSec: 0, + resetMs: 0, + fullResetSec: 0, + fullResetMs: 0, + }; + } + + if (factor < 0) { + throw new Error(`Rate limit factor is zero or negative: ${factor}`); + } + + if (isLegacyRateLimit(limit)) { + return await this.limitLegacy(limit, actor, factor); + } else { + return await this.limitBucket(limit, actor, factor); + } + } + + private async limitLegacy(limit: Keyed, actor: string, factor: number): Promise { + const promises: Promise[] = []; + + // The "min" limit - if present - is handled directly. + if (hasMinLimit(limit)) { + promises.push( + this.limitMin(limit, actor, factor), + ); + } + + // Convert the "max" limit into a leaky bucket with 1 drip / second rate. + if (limit.max != null && limit.duration != null) { + promises.push( + this.limitBucket({ + type: 'bucket', + key: limit.key, + size: limit.max, + dripRate: Math.max(Math.round(limit.duration / limit.max), 1), + }, actor, factor), + ); + } + + const [lim1, lim2] = await Promise.all(promises); + return { + blocked: (lim1?.blocked || lim2?.blocked) ?? false, + remaining: Math.min(lim1?.remaining ?? Number.MAX_SAFE_INTEGER, lim2?.remaining ?? Number.MAX_SAFE_INTEGER), + resetSec: Math.max(lim1?.resetSec ?? 0, lim2?.resetSec ?? 0), + resetMs: Math.max(lim1?.resetMs ?? 0, lim2?.resetMs ?? 0), + fullResetSec: Math.max(lim1?.fullResetSec ?? 0, lim2?.fullResetSec ?? 0), + fullResetMs: Math.max(lim1?.fullResetMs ?? 0, lim2?.fullResetMs ?? 0), + }; + } + + private async limitMin(limit: Keyed & { minInterval: number }, actor: string, factor: number): Promise { + if (limit.minInterval === 0) return null; + if (limit.minInterval < 0) throw new Error(`Invalid rate limit ${limit.key}: minInterval is negative (${limit.minInterval})`); + + const counter = await this.getLimitCounter(limit, actor, 'min'); + const minInterval = Math.max(Math.ceil(limit.minInterval * factor), 0); + + // Update expiration + if (counter.c > 0) { + const isCleared = this.timeService.now - counter.t >= minInterval; + if (isCleared) { + counter.c = 0; + } + } + + const blocked = counter.c > 0; + if (!blocked) { + counter.c++; + counter.t = this.timeService.now; + } + + // Calculate limit status + const resetMs = Math.max(Math.ceil(minInterval - (this.timeService.now - counter.t)), 0); + const resetSec = Math.ceil(resetMs / 1000); + const limitInfo: LimitInfo = { blocked, remaining: 0, resetSec, resetMs, fullResetSec: resetSec, fullResetMs: resetMs }; + + // Update the limit counter, but not if blocked + if (!blocked) { + // Don't await, or we will slow down the API. + this.setLimitCounter(limit, actor, counter, resetSec, 'min') + .catch(err => this.logger.error(`Failed to update limit ${limit.key}:min for ${actor}:`, err)); + } + + return limitInfo; + } + + private async limitBucket(limit: Keyed, actor: string, factor: number): Promise { + if (limit.size < 1) throw new Error(`Invalid rate limit ${limit.key}: size is less than 1 (${limit.size})`); + if (limit.dripRate != null && limit.dripRate < 1) throw new Error(`Invalid rate limit ${limit.key}: dripRate is less than 1 (${limit.dripRate})`); + if (limit.dripSize != null && limit.dripSize < 1) throw new Error(`Invalid rate limit ${limit.key}: dripSize is less than 1 (${limit.dripSize})`); + + const counter = await this.getLimitCounter(limit, actor, 'bucket'); + const bucketSize = Math.max(Math.ceil(limit.size / factor), 1); + const dripRate = Math.ceil(limit.dripRate ?? 1000); + const dripSize = Math.ceil(limit.dripSize ?? 1); + + // Update drips + if (counter.c > 0) { + const dripsSinceLastTick = Math.floor((this.timeService.now - counter.t) / dripRate) * dripSize; + counter.c = Math.max(counter.c - dripsSinceLastTick, 0); + } + + const blocked = counter.c >= bucketSize; + if (!blocked) { + counter.c++; + counter.t = this.timeService.now; + } + + // Calculate limit status + const remaining = Math.max(bucketSize - counter.c, 0); + const resetMs = remaining > 0 ? 0 : Math.max(dripRate - (this.timeService.now - counter.t), 0); + const resetSec = Math.ceil(resetMs / 1000); + const fullResetMs = Math.ceil(counter.c / dripSize) * dripRate; + const fullResetSec = Math.ceil(fullResetMs / 1000); + const limitInfo: LimitInfo = { blocked, remaining, resetSec, resetMs, fullResetSec, fullResetMs }; + + // Update the limit counter, but not if blocked + if (!blocked) { + // Don't await, or we will slow down the API. + this.setLimitCounter(limit, actor, counter, fullResetSec, 'bucket') + .catch(err => this.logger.error(`Failed to update limit ${limit.key} for ${actor}:`, err)); + } + + return limitInfo; + } + + private async getLimitCounter(limit: Keyed, actor: string, subject: string): Promise { + const key = createLimitKey(limit, actor, subject); + + const value = await this.redisClient.get(key); + if (value == null) { + return { t: 0, c: 0 }; + } + + return JSON.parse(value); + } + + private async setLimitCounter(limit: Keyed, actor: string, counter: LimitCounter, expiration: number, subject: string): Promise { + const key = createLimitKey(limit, actor, subject); + const value = JSON.stringify(counter); + const expirationSec = Math.max(expiration, 1); + await this.redisClient.set(key, value, 'EX', expirationSec); + } +} + +function createLimitKey(limit: Keyed, actor: string, subject: string): string { + return `rl_${actor}_${limit.key}_${subject}`; +} + +export interface LimitCounter { + /** Timestamp */ + t: number; + + /** Counter */ + c: number; +} diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 9b8464f705..e3fd1312ae 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -7,6 +7,8 @@ import { EventEmitter } from 'events'; import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import * as WebSocket from 'ws'; +import proxyAddr from 'proxy-addr'; +import ms from 'ms'; import { DI } from '@/di-symbols.js'; import type { UsersRepository, MiAccessToken } from '@/models/_.js'; import { NoteReadService } from '@/core/NoteReadService.js'; @@ -16,18 +18,15 @@ import { CacheService } from '@/core/CacheService.js'; import { MiLocalUser } from '@/models/User.js'; import { UserService } from '@/core/UserService.js'; import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { getIpHash } from '@/misc/get-ip-hash.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; import MainStreamConnection from './stream/Connection.js'; import { ChannelsService } from './stream/ChannelsService.js'; -import { RateLimiterService } from './RateLimiterService.js'; -import { RoleService } from '@/core/RoleService.js'; -import { getIpHash } from '@/misc/get-ip-hash.js'; -import proxyAddr from 'proxy-addr'; -import ms from 'ms'; import type * as http from 'node:http'; import type { IEndpointMeta } from './endpoints.js'; -import { LoggerService } from '@/core/LoggerService.js'; -import type Logger from '@/logger.js'; @Injectable() export class StreamingApiServerService { @@ -49,7 +48,7 @@ export class StreamingApiServerService { private notificationService: NotificationService, private usersService: UserService, private channelFollowingService: ChannelFollowingService, - private rateLimiterService: RateLimiterService, + private rateLimiterService: SkRateLimiterService, private roleService: RoleService, private loggerService: LoggerService, ) { @@ -73,9 +72,8 @@ export class StreamingApiServerService { if (factor <= 0) return false; // Rate limit - return await this.rateLimiterService.limit(limit, limitActor, factor) - .then(() => { return false; }) - .catch(err => { return true; }); + const rateLimit = await this.rateLimiterService.limit(limit, limitActor, factor); + return rateLimit.blocked; } @bindThis diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 14e002929a..7eb18fbfe2 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -5,6 +5,7 @@ import { permissions } from 'misskey-js'; import type { KeyOf, Schema } from '@/misc/json-schema.js'; +import type { RateLimit } from '@/misc/rate-limit-utils.js'; import * as ep___admin_abuseReport_notificationRecipient_list from '@/server/api/endpoints/admin/abuse-report/notification-recipient/list.js'; @@ -855,30 +856,7 @@ interface IEndpointMetaBase { * エンドポイントのリミテーションに関するやつ * 省略した場合はリミテーションは無いものとして解釈されます。 */ - readonly limit?: { - - /** - * 複数のエンドポイントでリミットを共有したい場合に指定するキー - */ - readonly key?: string; - - /** - * リミットを適用する期間(ms) - * このプロパティを設定する場合、max プロパティも設定する必要があります。 - */ - readonly duration?: number; - - /** - * durationで指定した期間内にいくつまでリクエストできるのか - * このプロパティを設定する場合、duration プロパティも設定する必要があります。 - */ - readonly max?: number; - - /** - * 最低でもどれくらいの間隔を開けてリクエストしなければならないか(ms) - */ - readonly minInterval?: number; - }; + readonly limit?: Readonly; /** * ファイルの添付を必要とするか否か diff --git a/packages/backend/src/server/api/endpoints/endpoint.ts b/packages/backend/src/server/api/endpoints/endpoint.ts index 7629cd7a67..a1dbb26431 100644 --- a/packages/backend/src/server/api/endpoints/endpoint.ts +++ b/packages/backend/src/server/api/endpoints/endpoint.ts @@ -29,10 +29,13 @@ export const meta = { }, }, - // 5 calls per second + // 1000 max @ 1/10ms drip = 10/sec average. + // Large bucket is ok because this is a fairly lightweight endpoint. limit: { - duration: 1000, - max: 5, + type: 'bucket', + + size: 1000, + dripRate: 10, }, } as const; diff --git a/packages/backend/test/unit/SigninWithPasskeyApiService.ts b/packages/backend/test/unit/SigninWithPasskeyApiService.ts index 4d73ba5af1..7df991c15c 100644 --- a/packages/backend/test/unit/SigninWithPasskeyApiService.ts +++ b/packages/backend/test/unit/SigninWithPasskeyApiService.ts @@ -17,16 +17,24 @@ import { GlobalModule } from '@/GlobalModule.js'; import { DI } from '@/di-symbols.js'; import { CoreModule } from '@/core/CoreModule.js'; import { SigninWithPasskeyApiService } from '@/server/api/SigninWithPasskeyApiService.js'; -import { RateLimiterService } from '@/server/api/RateLimiterService.js'; import { WebAuthnService } from '@/core/WebAuthnService.js'; import { SigninService } from '@/server/api/SigninService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; +import { LimitInfo } from '@/misc/rate-limit-utils.js'; const moduleMocker = new ModuleMocker(global); class FakeLimiter { - public async limit() { - return; + public async limit(): Promise { + return { + blocked: false, + remaining: Number.MAX_SAFE_INTEGER, + resetMs: 0, + resetSec: 0, + fullResetMs: 0, + fullResetSec: 0, + }; } } @@ -90,7 +98,7 @@ describe('SigninWithPasskeyApiService', () => { imports: [GlobalModule, CoreModule], providers: [ SigninWithPasskeyApiService, - { provide: RateLimiterService, useClass: FakeLimiter }, + { provide: SkRateLimiterService, useClass: FakeLimiter }, { provide: SigninService, useClass: FakeSigninService }, ], }).useMocker((token) => { diff --git a/packages/backend/test/unit/misc/rate-limit-utils-tests.ts b/packages/backend/test/unit/misc/rate-limit-utils-tests.ts new file mode 100644 index 0000000000..ea56d170f7 --- /dev/null +++ b/packages/backend/test/unit/misc/rate-limit-utils-tests.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { jest } from '@jest/globals'; +import { Mock } from 'jest-mock'; +import type { FastifyReply } from 'fastify'; +import { LimitInfo, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +describe(sendRateLimitHeaders, () => { + let mockHeader: Mock<((name: string, value: unknown) => void)> = null!; + let mockReply: FastifyReply = null!; + let fakeInfo: LimitInfo = null!; + + beforeEach(() => { + mockHeader = jest.fn<((name: string, value: unknown) => void)>(); + mockReply = { + header: mockHeader, + } as unknown as FastifyReply; + fakeInfo = { + blocked: false, + remaining: 1, + resetSec: 1, + resetMs: 567, + fullResetSec: 10, + fullResetMs: 9876, + }; + }); + + it('should send X-RateLimit-Clear', () => { + sendRateLimitHeaders(mockReply, fakeInfo); + + expect(mockHeader).toHaveBeenCalledWith('X-RateLimit-Clear', '9.876'); + }); + + it('should send X-RateLimit-Remaining', () => { + sendRateLimitHeaders(mockReply, fakeInfo); + + expect(mockHeader).toHaveBeenCalledWith('X-RateLimit-Remaining', '1'); + }); + + describe('when limit is blocked', () => { + it('should send X-RateLimit-Reset', () => { + fakeInfo.blocked = true; + + sendRateLimitHeaders(mockReply, fakeInfo); + + expect(mockHeader).toHaveBeenCalledWith('X-RateLimit-Reset', '0.567'); + }); + + it('should send Retry-After', () => { + fakeInfo.blocked = true; + + sendRateLimitHeaders(mockReply, fakeInfo); + + expect(mockHeader).toHaveBeenCalledWith('Retry-After', '1'); + }); + }); +}); diff --git a/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts b/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts new file mode 100644 index 0000000000..dbf7795fc6 --- /dev/null +++ b/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts @@ -0,0 +1,857 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { KEYWORD } from 'color-convert/conversions.js'; +import { jest } from '@jest/globals'; +import type Redis from 'ioredis'; +import { LimitCounter, SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import { BucketRateLimit, Keyed, LegacyRateLimit } from '@/misc/rate-limit-utils.js'; + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ + +describe(SkRateLimiterService, () => { + let mockTimeService: { now: number, date: Date } = null!; + let mockRedisGet: ((key: string) => string | null) | undefined = undefined; + let mockRedisSet: ((args: unknown[]) => void) | undefined = undefined; + let mockEnvironment: Record = null!; + let serviceUnderTest: () => SkRateLimiterService = null!; + + let loggedMessages: { level: string, data: unknown[] }[] = []; + + beforeEach(() => { + mockTimeService = { + now: 0, + get date() { + return new Date(mockTimeService.now); + }, + }; + + mockRedisGet = undefined; + mockRedisSet = undefined; + const mockRedisClient = { + get(key: string) { + if (mockRedisGet) return Promise.resolve(mockRedisGet(key)); + else return Promise.resolve(null); + }, + set(...args: unknown[]): Promise { + if (mockRedisSet) mockRedisSet(args); + return Promise.resolve(); + }, + } as unknown as Redis.Redis; + + mockEnvironment = Object.create(process.env); + mockEnvironment.NODE_ENV = 'production'; + const mockEnvService = { + env: mockEnvironment, + }; + + loggedMessages = []; + const mockLogService = { + getLogger() { + return { + createSubLogger(context: string, color?: KEYWORD) { + return mockLogService.getLogger(context, color); + }, + error(...data: unknown[]) { + loggedMessages.push({ level: 'error', data }); + }, + warn(...data: unknown[]) { + loggedMessages.push({ level: 'warn', data }); + }, + succ(...data: unknown[]) { + loggedMessages.push({ level: 'succ', data }); + }, + debug(...data: unknown[]) { + loggedMessages.push({ level: 'debug', data }); + }, + info(...data: unknown[]) { + loggedMessages.push({ level: 'info', data }); + }, + }; + }, + } as unknown as LoggerService; + + let service: SkRateLimiterService | undefined = undefined; + serviceUnderTest = () => { + return service ??= new SkRateLimiterService(mockTimeService, mockRedisClient, mockLogService, mockEnvService); + }; + }); + + function expectNoUnhandledErrors() { + const unhandledErrors = loggedMessages.filter(m => m.level === 'error'); + if (unhandledErrors.length > 0) { + throw new Error(`Test failed: got unhandled errors ${unhandledErrors.join('\n')}`); + } + } + + describe('limit', () => { + const actor = 'actor'; + const key = 'test'; + + let counter: LimitCounter | undefined = undefined; + let minCounter: LimitCounter | undefined = undefined; + + beforeEach(() => { + counter = undefined; + minCounter = undefined; + + mockRedisGet = (key: string) => { + if (key === 'rl_actor_test_bucket' && counter) { + return JSON.stringify(counter); + } + + if (key === 'rl_actor_test_min' && minCounter) { + return JSON.stringify(minCounter); + } + + return null; + }; + + mockRedisSet = (args: unknown[]) => { + const [key, value] = args; + + if (key === 'rl_actor_test_bucket') { + if (value == null) counter = undefined; + else if (typeof(value) === 'string') counter = JSON.parse(value); + else throw new Error('invalid redis call'); + } + + if (key === 'rl_actor_test_min') { + if (value == null) minCounter = undefined; + else if (typeof(value) === 'string') minCounter = JSON.parse(value); + else throw new Error('invalid redis call'); + } + }; + }); + + it('should bypass in non-production', async () => { + mockEnvironment.NODE_ENV = 'test'; + + const info = await serviceUnderTest().limit({ key: 'l', type: undefined, max: 0 }, 'actor'); + + expect(info.blocked).toBeFalsy(); + expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER); + expect(info.resetSec).toBe(0); + expect(info.resetMs).toBe(0); + expect(info.fullResetSec).toBe(0); + expect(info.fullResetMs).toBe(0); + }); + + describe('with bucket limit', () => { + let limit: Keyed = null!; + + beforeEach(() => { + limit = { + type: 'bucket', + key: 'test', + size: 1, + }; + }); + + it('should allow when limit is not reached', async () => { + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.blocked).toBeFalsy(); + }); + + it('should not error when allowed', async () => { + await serviceUnderTest().limit(limit, actor); + + expectNoUnhandledErrors(); + }); + + it('should return correct info when allowed', async () => { + limit.size = 2; + counter = { c: 1, t: 0 }; + + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.remaining).toBe(0); + expect(info.resetSec).toBe(1); + expect(info.resetMs).toBe(1000); + expect(info.fullResetSec).toBe(2); + expect(info.fullResetMs).toBe(2000); + }); + + it('should increment counter when called', async () => { + await serviceUnderTest().limit(limit, actor); + + expect(counter).not.toBeUndefined(); + expect(counter?.c).toBe(1); + }); + + it('should set timestamp when called', async () => { + mockTimeService.now = 1000; + + await serviceUnderTest().limit(limit, actor); + + expect(counter).not.toBeUndefined(); + expect(counter?.t).toBe(1000); + }); + + it('should decrement counter when dripRate has passed', async () => { + counter = { c: 2, t: 0 }; + mockTimeService.now = 2000; + + await serviceUnderTest().limit(limit, actor); + + expect(counter).not.toBeUndefined(); + expect(counter?.c).toBe(1); // 2 (starting) - 2 (2x1 drip) + 1 (call) = 1 + }); + + it('should decrement counter by dripSize', async () => { + counter = { c: 2, t: 0 }; + limit.dripSize = 2; + mockTimeService.now = 1000; + + await serviceUnderTest().limit(limit, actor); + + expect(counter).not.toBeUndefined(); + expect(counter?.c).toBe(1); // 2 (starting) - 2 (1x2 drip) + 1 (call) = 1 + }); + + it('should maintain counter between calls over time', async () => { + limit.size = 5; + + await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1 + mockTimeService.now += 1000; // 1 - 1 = 0 + await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1 + await serviceUnderTest().limit(limit, actor); // 1 + 1 = 2 + await serviceUnderTest().limit(limit, actor); // 2 + 1 = 3 + mockTimeService.now += 1000; // 3 - 1 = 2 + mockTimeService.now += 1000; // 2 - 1 = 1 + await serviceUnderTest().limit(limit, actor); // 1 + 1 = 2 + + expect(counter?.c).toBe(2); + expect(counter?.t).toBe(3000); + }); + + it('should log error and continue when update fails', async () => { + mockRedisSet = () => { + throw new Error('test error'); + }; + + await serviceUnderTest().limit(limit, actor); + + const matchingError = loggedMessages + .find(m => m.level === 'error' && m.data + .some(d => typeof(d) === 'string' && d.includes('Failed to update limit'))); + expect(matchingError).toBeTruthy(); + }); + + it('should block when bucket is filled', async () => { + counter = { c: 1, t: 0 }; + + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.blocked).toBeTruthy(); + }); + + it('should calculate correct info when blocked', async () => { + counter = { c: 1, t: 0 }; + + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.resetSec).toBe(1); + expect(info.resetMs).toBe(1000); + expect(info.fullResetSec).toBe(1); + expect(info.fullResetMs).toBe(1000); + }); + + it('should allow when bucket is filled but should drip', async () => { + counter = { c: 1, t: 0 }; + mockTimeService.now = 1000; + + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.blocked).toBeFalsy(); + }); + + it('should scale limit by factor', async () => { + counter = { c: 1, t: 0 }; + + const i1 = await serviceUnderTest().limit(limit, actor, 0.5); // 1 + 1 = 2 + const i2 = await serviceUnderTest().limit(limit, actor, 0.5); // 2 + 1 = 3 + + expect(i1.blocked).toBeFalsy(); + expect(i2.blocked).toBeTruthy(); + }); + + it('should set key expiration', async () => { + const mock = jest.fn(mockRedisSet); + mockRedisSet = mock; + + await serviceUnderTest().limit(limit, actor); + + expect(mock).toHaveBeenCalledWith(['rl_actor_test_bucket', '{"t":0,"c":1}', 'EX', 1]); + }); + + it('should not increment when already blocked', async () => { + counter = { c: 1, t: 0 }; + mockTimeService.now += 100; + + await serviceUnderTest().limit(limit, actor); + + expect(counter?.c).toBe(1); + expect(counter?.t).toBe(0); + }); + + it('should skip if factor is zero', async () => { + const info = await serviceUnderTest().limit(limit, actor, 0); + + expect(info.blocked).toBeFalsy(); + expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER); + }); + + it('should throw if factor is negative', async () => { + const promise = serviceUnderTest().limit(limit, actor, -1); + + await expect(promise).rejects.toThrow(/factor is zero or negative/); + }); + + it('should throw if size is zero', async () => { + limit.size = 0; + + const promise = serviceUnderTest().limit(limit, actor); + + await expect(promise).rejects.toThrow(/size is less than 1/); + }); + + it('should throw if size is negative', async () => { + limit.size = -1; + + const promise = serviceUnderTest().limit(limit, actor); + + await expect(promise).rejects.toThrow(/size is less than 1/); + }); + + it('should throw if size is fraction', async () => { + limit.size = 0.5; + + const promise = serviceUnderTest().limit(limit, actor); + + await expect(promise).rejects.toThrow(/size is less than 1/); + }); + + it('should throw if dripRate is zero', async () => { + limit.dripRate = 0; + + const promise = serviceUnderTest().limit(limit, actor); + + await expect(promise).rejects.toThrow(/dripRate is less than 1/); + }); + + it('should throw if dripRate is negative', async () => { + limit.dripRate = -1; + + const promise = serviceUnderTest().limit(limit, actor); + + await expect(promise).rejects.toThrow(/dripRate is less than 1/); + }); + + it('should throw if dripRate is fraction', async () => { + limit.dripRate = 0.5; + + const promise = serviceUnderTest().limit(limit, actor); + + await expect(promise).rejects.toThrow(/dripRate is less than 1/); + }); + + it('should throw if dripSize is zero', async () => { + limit.dripSize = 0; + + const promise = serviceUnderTest().limit(limit, actor); + + await expect(promise).rejects.toThrow(/dripSize is less than 1/); + }); + + it('should throw if dripSize is negative', async () => { + limit.dripSize = -1; + + const promise = serviceUnderTest().limit(limit, actor); + + await expect(promise).rejects.toThrow(/dripSize is less than 1/); + }); + + it('should throw if dripSize is fraction', async () => { + limit.dripSize = 0.5; + + const promise = serviceUnderTest().limit(limit, actor); + + await expect(promise).rejects.toThrow(/dripSize is less than 1/); + }); + }); + + describe('with min interval', () => { + let limit: Keyed = null!; + + beforeEach(() => { + limit = { + type: undefined, + key, + minInterval: 1000, + }; + }); + + it('should allow when limit is not reached', async () => { + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.blocked).toBeFalsy(); + }); + + it('should not error when allowed', async () => { + await serviceUnderTest().limit(limit, actor); + + expectNoUnhandledErrors(); + }); + + it('should calculate correct info when allowed', async () => { + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.remaining).toBe(0); + expect(info.resetSec).toBe(1); + expect(info.resetMs).toBe(1000); + expect(info.fullResetSec).toBe(1); + expect(info.fullResetMs).toBe(1000); + }); + + it('should increment counter when called', async () => { + await serviceUnderTest().limit(limit, actor); + + expect(minCounter).not.toBeUndefined(); + expect(minCounter?.c).toBe(1); + }); + + it('should set timestamp when called', async () => { + mockTimeService.now = 1000; + + await serviceUnderTest().limit(limit, actor); + + expect(minCounter).not.toBeUndefined(); + expect(minCounter?.t).toBe(1000); + }); + + it('should decrement counter when minInterval has passed', async () => { + minCounter = { c: 1, t: 0 }; + mockTimeService.now = 1000; + + await serviceUnderTest().limit(limit, actor); + + expect(minCounter).not.toBeUndefined(); + expect(minCounter?.c).toBe(1); // 1 (starting) - 1 (interval) + 1 (call) = 1 + }); + + it('should reset counter entirely', async () => { + minCounter = { c: 2, t: 0 }; + mockTimeService.now = 1000; + + await serviceUnderTest().limit(limit, actor); + + expect(minCounter).not.toBeUndefined(); + expect(minCounter?.c).toBe(1); // 2 (starting) - 2 (interval) + 1 (call) = 1 + }); + + it('should maintain counter between calls over time', async () => { + await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1 + mockTimeService.now += 1000; // 1 - 1 = 0 + await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1 + await serviceUnderTest().limit(limit, actor); // blocked + await serviceUnderTest().limit(limit, actor); // blocked + mockTimeService.now += 1000; // 1 - 1 = 0 + mockTimeService.now += 1000; // 0 - 1 = 0 + await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1 + + expect(minCounter?.c).toBe(1); + expect(minCounter?.t).toBe(3000); + }); + + it('should log error and continue when update fails', async () => { + mockRedisSet = () => { + throw new Error('test error'); + }; + + await serviceUnderTest().limit(limit, actor); + + const matchingError = loggedMessages + .find(m => m.level === 'error' && m.data + .some(d => typeof(d) === 'string' && d.includes('Failed to update limit'))); + expect(matchingError).toBeTruthy(); + }); + + it('should block when interval exceeded', async () => { + minCounter = { c: 1, t: 0 }; + + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.blocked).toBeTruthy(); + }); + + it('should calculate correct info when blocked', async () => { + minCounter = { c: 1, t: 0 }; + + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.resetSec).toBe(1); + expect(info.resetMs).toBe(1000); + expect(info.fullResetSec).toBe(1); + expect(info.fullResetMs).toBe(1000); + }); + + it('should allow when bucket is filled but interval has passed', async () => { + minCounter = { c: 1, t: 0 }; + mockTimeService.now = 1000; + + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.blocked).toBeFalsy(); + }); + + it('should scale interval by factor', async () => { + minCounter = { c: 1, t: 0 }; + mockTimeService.now += 500; + + const info = await serviceUnderTest().limit(limit, actor, 0.5); + + expect(info.blocked).toBeFalsy(); + }); + + it('should set key expiration', async () => { + const mock = jest.fn(mockRedisSet); + mockRedisSet = mock; + + await serviceUnderTest().limit(limit, actor); + + expect(mock).toHaveBeenCalledWith(['rl_actor_test_min', '{"t":0,"c":1}', 'EX', 1]); + }); + + it('should not increment when already blocked', async () => { + minCounter = { c: 1, t: 0 }; + mockTimeService.now += 100; + + await serviceUnderTest().limit(limit, actor); + + expect(minCounter?.c).toBe(1); + expect(minCounter?.t).toBe(0); + }); + + it('should skip if factor is zero', async () => { + const info = await serviceUnderTest().limit(limit, actor, 0); + + expect(info.blocked).toBeFalsy(); + expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER); + }); + + it('should throw if factor is negative', async () => { + const promise = serviceUnderTest().limit(limit, actor, -1); + + await expect(promise).rejects.toThrow(/factor is zero or negative/); + }); + + it('should skip if minInterval is zero', async () => { + limit.minInterval = 0; + + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.blocked).toBeFalsy(); + expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER); + }); + + it('should throw if minInterval is negative', async () => { + limit.minInterval = -1; + + const promise = serviceUnderTest().limit(limit, actor); + + await expect(promise).rejects.toThrow(/minInterval is negative/); + }); + }); + + describe('with legacy limit', () => { + let limit: Keyed = null!; + + beforeEach(() => { + limit = { + type: undefined, + key, + max: 1, + duration: 1000, + }; + }); + + it('should allow when limit is not reached', async () => { + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.blocked).toBeFalsy(); + }); + + it('should not error when allowed', async () => { + await serviceUnderTest().limit(limit, actor); + + expectNoUnhandledErrors(); + }); + + it('should infer dripRate from duration', async () => { + limit.max = 10; + limit.duration = 10000; + counter = { c: 10, t: 0 }; + + const i1 = await serviceUnderTest().limit(limit, actor); + mockTimeService.now += 1000; + const i2 = await serviceUnderTest().limit(limit, actor); + mockTimeService.now += 2000; + const i3 = await serviceUnderTest().limit(limit, actor); + const i4 = await serviceUnderTest().limit(limit, actor); + const i5 = await serviceUnderTest().limit(limit, actor); + mockTimeService.now += 2000; + const i6 = await serviceUnderTest().limit(limit, actor); + + expect(i1.blocked).toBeTruthy(); + expect(i2.blocked).toBeFalsy(); + expect(i3.blocked).toBeFalsy(); + expect(i4.blocked).toBeFalsy(); + expect(i5.blocked).toBeTruthy(); + expect(i6.blocked).toBeFalsy(); + }); + + it('should calculate correct info when allowed', async () => { + limit.max = 10; + limit.duration = 10000; + counter = { c: 10, t: 0 }; + mockTimeService.now += 2000; + + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.remaining).toBe(1); + expect(info.resetSec).toBe(0); + expect(info.resetMs).toBe(0); + expect(info.fullResetSec).toBe(9); + expect(info.fullResetMs).toBe(9000); + }); + + it('should calculate correct info when blocked', async () => { + limit.max = 10; + limit.duration = 10000; + counter = { c: 10, t: 0 }; + + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.remaining).toBe(0); + expect(info.resetSec).toBe(1); + expect(info.resetMs).toBe(1000); + expect(info.fullResetSec).toBe(10); + expect(info.fullResetMs).toBe(10000); + }); + + it('should allow when bucket is filled but interval has passed', async () => { + counter = { c: 10, t: 0 }; + mockTimeService.now = 1000; + + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.blocked).toBeTruthy(); + }); + + it('should scale limit by factor', async () => { + counter = { c: 10, t: 0 }; + + const info = await serviceUnderTest().limit(limit, actor, 0.5); // 10 + 1 = 11 + + expect(info.blocked).toBeTruthy(); + }); + + it('should set key expiration', async () => { + const mock = jest.fn(mockRedisSet); + mockRedisSet = mock; + + await serviceUnderTest().limit(limit, actor); + + expect(mock).toHaveBeenCalledWith(['rl_actor_test_bucket', '{"t":0,"c":1}', 'EX', 1]); + }); + + it('should not increment when already blocked', async () => { + counter = { c: 1, t: 0 }; + mockTimeService.now += 100; + + await serviceUnderTest().limit(limit, actor); + + expect(counter?.c).toBe(1); + expect(counter?.t).toBe(0); + }); + + it('should not allow dripRate to be lower than 0', async () => { + // real-world case; taken from StreamingApiServerService + limit.max = 4096; + limit.duration = 2000; + counter = { c: 4096, t: 0 }; + + const i1 = await serviceUnderTest().limit(limit, actor); + mockTimeService.now = 1; + const i2 = await serviceUnderTest().limit(limit, actor); + + expect(i1.blocked).toBeTruthy(); + expect(i2.blocked).toBeFalsy(); + }); + + it('should skip if factor is zero', async () => { + const info = await serviceUnderTest().limit(limit, actor, 0); + + expect(info.blocked).toBeFalsy(); + expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER); + }); + + it('should throw if factor is negative', async () => { + const promise = serviceUnderTest().limit(limit, actor, -1); + + await expect(promise).rejects.toThrow(/factor is zero or negative/); + }); + + it('should throw if max is zero', async () => { + limit.max = 0; + + const promise = serviceUnderTest().limit(limit, actor); + + await expect(promise).rejects.toThrow(/size is less than 1/); + }); + + it('should throw if max is negative', async () => { + limit.max = -1; + + const promise = serviceUnderTest().limit(limit, actor); + + await expect(promise).rejects.toThrow(/size is less than 1/); + }); + }); + + describe('with legacy limit and min interval', () => { + let limit: Keyed = null!; + + beforeEach(() => { + limit = { + type: undefined, + key, + max: 5, + duration: 5000, + minInterval: 1000, + }; + }); + + it('should allow when limit is not reached', async () => { + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.blocked).toBeFalsy(); + }); + + it('should not error when allowed', async () => { + await serviceUnderTest().limit(limit, actor); + + expectNoUnhandledErrors(); + }); + + it('should block when limit exceeded', async () => { + counter = { c: 5, t: 0 }; + + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.blocked).toBeTruthy(); + }); + + it('should block when minInterval exceeded', async () => { + minCounter = { c: 1, t: 0 }; + + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.blocked).toBeTruthy(); + }); + + it('should calculate correct info when allowed', async () => { + counter = { c: 1, t: 0 }; + + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.remaining).toBe(0); + expect(info.resetSec).toBe(1); + expect(info.resetMs).toBe(1000); + expect(info.fullResetSec).toBe(2); + expect(info.fullResetMs).toBe(2000); + }); + + it('should calculate correct info when blocked by limit', async () => { + counter = { c: 5, t: 0 }; + + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.remaining).toBe(0); + expect(info.resetSec).toBe(1); + expect(info.resetMs).toBe(1000); + expect(info.fullResetSec).toBe(5); + expect(info.fullResetMs).toBe(5000); + }); + + it('should calculate correct info when blocked by minInterval', async () => { + minCounter = { c: 1, t: 0 }; + + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.remaining).toBe(0); + expect(info.resetSec).toBe(1); + expect(info.resetMs).toBe(1000); + expect(info.fullResetSec).toBe(1); + expect(info.fullResetMs).toBe(1000); + }); + + it('should allow when counter is filled but interval has passed', async () => { + counter = { c: 5, t: 0 }; + mockTimeService.now = 1000; + + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.blocked).toBeFalsy(); + }); + + it('should allow when minCounter is filled but interval has passed', async () => { + minCounter = { c: 1, t: 0 }; + mockTimeService.now = 1000; + + const info = await serviceUnderTest().limit(limit, actor); + + expect(info.blocked).toBeFalsy(); + }); + + it('should scale limit and interval by factor', async () => { + counter = { c: 5, t: 0 }; + minCounter = { c: 1, t: 0 }; + mockTimeService.now += 500; + + const info = await serviceUnderTest().limit(limit, actor, 0.5); + + expect(info.blocked).toBeFalsy(); + }); + + it('should set key expiration', async () => { + const mock = jest.fn(mockRedisSet); + mockRedisSet = mock; + + await serviceUnderTest().limit(limit, actor); + + expect(mock).toHaveBeenCalledWith(['rl_actor_test_bucket', '{"t":0,"c":1}', 'EX', 1]); + expect(mock).toHaveBeenCalledWith(['rl_actor_test_min', '{"t":0,"c":1}', 'EX', 1]); + }); + + it('should not increment when already blocked', async () => { + counter = { c: 5, t: 0 }; + minCounter = { c: 1, t: 0 }; + mockTimeService.now += 100; + + await serviceUnderTest().limit(limit, actor); + + expect(counter?.c).toBe(5); + expect(counter?.t).toBe(0); + expect(minCounter?.c).toBe(1); + expect(minCounter?.t).toBe(0); + }); + }); + }); +});