move all Rate Limit type defs to rate-limit-utils.ts

This commit is contained in:
Hazelnoot 2024-12-08 11:33:57 -05:00
parent 8b091f77ca
commit 7c002ce56e
7 changed files with 144 additions and 132 deletions

View file

@ -4,7 +4,125 @@
*/ */
import { FastifyReply } from 'fastify'; import { FastifyReply } from 'fastify';
import { LimitInfo } from '@/server/api/SkRateLimiterService.js';
export type RateLimit = BucketRateLimit | LegacyRateLimit;
/**
* 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 { export function sendRateLimitHeaders(reply: FastifyReply, info: LimitInfo): void {
// Number of seconds until the limit has fully reset. // Number of seconds until the limit has fully reset.

View file

@ -31,8 +31,8 @@ import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.
import { getIpHash } from '@/misc/get-ip-hash.js'; import { getIpHash } from '@/misc/get-ip-hash.js';
import { AuthenticateService } from '@/server/api/AuthenticateService.js'; import { AuthenticateService } from '@/server/api/AuthenticateService.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { RateLimit, SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; import { RateLimit, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
const _filename = fileURLToPath(import.meta.url); const _filename = fileURLToPath(import.meta.url);

View file

@ -18,8 +18,8 @@ import { createTemp } from '@/misc/create-temp.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; import { RateLimit, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
import { LegacyRateLimit, SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
import { ApiError } from './error.js'; import { ApiError } from './error.js';
import { ApiLoggerService } from './ApiLoggerService.js'; import { ApiLoggerService } from './ApiLoggerService.js';
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
@ -304,7 +304,7 @@ export class ApiCallService implements OnApplicationShutdown {
} }
// For endpoints without a limit, the default is 10 calls per second // 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, duration: 1000,
max: 10, max: 10,
}; };
@ -320,18 +320,17 @@ export class ApiCallService implements OnApplicationShutdown {
limitActor = getIpHash(request.ip); limitActor = getIpHash(request.ip);
} }
const limit = Object.assign({}, endpointLimit);
if (limit.key == null) {
(limit as any).key = ep.name;
}
// TODO: 毎リクエスト計算するのもあれだしキャッシュしたい // TODO: 毎リクエスト計算するのもあれだしキャッシュしたい
const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1; const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1;
if (factor > 0) { if (factor > 0) {
const limit = {
key: ep.name,
...endpointLimit,
} as RateLimit;
// Rate limit // Rate limit
const info = await this.rateLimiterService.limit(limit as LegacyRateLimit, limitActor, factor); const info = await this.rateLimiterService.limit(limit, limitActor, factor);
sendRateLimitHeaders(reply, info); sendRateLimitHeaders(reply, info);

View file

@ -5,97 +5,12 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis'; import Redis from 'ioredis';
import type { IEndpointMeta } from '@/server/api/endpoints.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { TimeService } from '@/core/TimeService.js'; import { TimeService } from '@/core/TimeService.js';
import { EnvService } from '@/core/EnvService.js'; import { EnvService } from '@/core/EnvService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { BucketRateLimit, LegacyRateLimit, LimitInfo, RateLimit, hasMinLimit, isLegacyRateLimit } from '@/misc/rate-limit-utils.js';
/**
* 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;
}
/**
* 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 RateLimit {
/**
* 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;
}
export type SupportedRateLimit = RateLimit | LegacyRateLimit;
export type LegacyRateLimit = IEndpointMeta['limit'] & { key: NonNullable<string>, type?: undefined };
export function isLegacyRateLimit(limit: SupportedRateLimit): limit is LegacyRateLimit {
return limit.type === undefined;
}
export function hasMinLimit(limit: LegacyRateLimit): limit is LegacyRateLimit & { minInterval: number } {
return !!limit.minInterval;
}
@Injectable() @Injectable()
export class SkRateLimiterService { export class SkRateLimiterService {
@ -116,10 +31,10 @@ export class SkRateLimiterService {
envService: EnvService, envService: EnvService,
) { ) {
this.logger = loggerService.getLogger('limiter'); this.logger = loggerService.getLogger('limiter');
this.disabled = envService.env.NODE_ENV !== 'production'; this.disabled = envService.env.NODE_ENV !== 'production'; // TODO disable in TEST *only*
} }
public async limit(limit: SupportedRateLimit, actor: string, factor = 1): Promise<LimitInfo> { public async limit(limit: RateLimit, actor: string, factor = 1): Promise<LimitInfo> {
if (this.disabled) { if (this.disabled) {
return { return {
blocked: false, blocked: false,
@ -211,7 +126,7 @@ export class SkRateLimiterService {
return limitInfo; return limitInfo;
} }
private async limitBucket(limit: RateLimit, actor: string, factor: number): Promise<LimitInfo> { private async limitBucket(limit: BucketRateLimit, actor: string, factor: number): Promise<LimitInfo> {
if (limit.size < 1) throw new Error(`Invalid rate limit ${limit.key}: size is less than 1 (${limit.size})`); 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.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})`); if (limit.dripSize != null && limit.dripSize < 1) throw new Error(`Invalid rate limit ${limit.key}: dripSize is less than 1 (${limit.dripSize})`);
@ -251,7 +166,7 @@ export class SkRateLimiterService {
return limitInfo; return limitInfo;
} }
private async getLimitCounter(limit: SupportedRateLimit, actor: string, subject: string): Promise<LimitCounter> { private async getLimitCounter(limit: RateLimit, actor: string, subject: string): Promise<LimitCounter> {
const key = createLimitKey(limit, actor, subject); const key = createLimitKey(limit, actor, subject);
const value = await this.redisClient.get(key); const value = await this.redisClient.get(key);
@ -262,7 +177,7 @@ export class SkRateLimiterService {
return JSON.parse(value); return JSON.parse(value);
} }
private async setLimitCounter(limit: SupportedRateLimit, actor: string, counter: LimitCounter, expiration: number, subject: string): Promise<void> { private async setLimitCounter(limit: RateLimit, actor: string, counter: LimitCounter, expiration: number, subject: string): Promise<void> {
const key = createLimitKey(limit, actor, subject); const key = createLimitKey(limit, actor, subject);
const value = JSON.stringify(counter); const value = JSON.stringify(counter);
const expirationSec = Math.max(expiration, 1); const expirationSec = Math.max(expiration, 1);
@ -270,7 +185,7 @@ export class SkRateLimiterService {
} }
} }
function createLimitKey(limit: SupportedRateLimit, actor: string, subject: string): string { function createLimitKey(limit: RateLimit, actor: string, subject: string): string {
return `rl_${actor}_${limit.key}_${subject}`; return `rl_${actor}_${limit.key}_${subject}`;
} }

View file

@ -5,6 +5,7 @@
import { permissions } from 'misskey-js'; import { permissions } from 'misskey-js';
import type { KeyOf, Schema } from '@/misc/json-schema.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 import * as ep___admin_abuseReport_notificationRecipient_list
from '@/server/api/endpoints/admin/abuse-report/notification-recipient/list.js'; from '@/server/api/endpoints/admin/abuse-report/notification-recipient/list.js';
@ -855,30 +856,7 @@ interface IEndpointMetaBase {
* *
* *
*/ */
readonly limit?: { readonly limit?: Readonly<RateLimit | Omit<RateLimit, 'key'>>;
/**
*
*/
readonly key?: string;
/**
* (ms)
* max
*/
readonly duration?: number;
/**
* durationで指定した期間内にいくつまでリクエストできるのか
* duration
*/
readonly max?: number;
/**
* (ms)
*/
readonly minInterval?: number;
};
/** /**
* *

View file

@ -20,7 +20,8 @@ import { SigninWithPasskeyApiService } from '@/server/api/SigninWithPasskeyApiSe
import { WebAuthnService } from '@/core/WebAuthnService.js'; import { WebAuthnService } from '@/core/WebAuthnService.js';
import { SigninService } from '@/server/api/SigninService.js'; import { SigninService } from '@/server/api/SigninService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { LimitInfo, SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
import { LimitInfo } from '@/misc/rate-limit-utils.js';
const moduleMocker = new ModuleMocker(global); const moduleMocker = new ModuleMocker(global);

View file

@ -6,8 +6,9 @@
import { KEYWORD } from 'color-convert/conversions.js'; import { KEYWORD } from 'color-convert/conversions.js';
import { jest } from '@jest/globals'; import { jest } from '@jest/globals';
import type Redis from 'ioredis'; import type Redis from 'ioredis';
import { LegacyRateLimit, LimitCounter, RateLimit, SkRateLimiterService } from '@/server/api/SkRateLimiterService.js'; import { LimitCounter, SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { BucketRateLimit, LegacyRateLimit } from '@/misc/rate-limit-utils.js';
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-unnecessary-condition */ /* eslint-disable @typescript-eslint/no-unnecessary-condition */
@ -141,7 +142,7 @@ describe(SkRateLimiterService, () => {
}); });
describe('with bucket limit', () => { describe('with bucket limit', () => {
let limit: RateLimit = null!; let limit: BucketRateLimit = null!;
beforeEach(() => { beforeEach(() => {
limit = { limit = {