diff options
| author | Hazelnoot <acomputerdog@gmail.com> | 2024-12-07 10:22:45 -0500 |
|---|---|---|
| committer | Hazelnoot <acomputerdog@gmail.com> | 2024-12-07 10:22:49 -0500 |
| commit | ffc2737478c6f9efd5de9fbaf526b13164727f87 (patch) | |
| tree | 416a391cdd024e11ad34dfc7707d28dcbbecce19 /packages/backend/src/server/api/ApiCallService.ts | |
| parent | merge: Fix Content-Length resetting for partial content length requests (!796) (diff) | |
| download | sharkey-ffc2737478c6f9efd5de9fbaf526b13164727f87.tar.gz sharkey-ffc2737478c6f9efd5de9fbaf526b13164727f87.tar.bz2 sharkey-ffc2737478c6f9efd5de9fbaf526b13164727f87.zip | |
implement SkRateLimiterService with Leaky Bucket rate limiting
Diffstat (limited to 'packages/backend/src/server/api/ApiCallService.ts')
| -rw-r--r-- | packages/backend/src/server/api/ApiCallService.ts | 64 |
1 files changed, 47 insertions, 17 deletions
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 6f51825494..14367e02bb 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -8,6 +8,7 @@ import * as fs from 'node:fs'; import * as stream from 'node:stream/promises'; import { Inject, Injectable } from '@nestjs/common'; import * as Sentry from '@sentry/node'; +import { LimiterInfo } from 'ratelimiter'; import { DI } from '@/di-symbols.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; @@ -18,6 +19,7 @@ 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 { isLimitInfo } from '@/server/api/SkRateLimiterService.js'; import { ApiError } from './error.js'; import { RateLimiterService } from './RateLimiterService.js'; import { ApiLoggerService } from './ApiLoggerService.js'; @@ -68,12 +70,17 @@ export class ApiCallService implements OnApplicationShutdown { } 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') { + if (isLimitInfo(info)) { + // Number of seconds to wait before trying again. Left for backwards compatibility. + reply.header('Retry-After', info.resetSec.toString()); + // Number of milliseconds to wait before trying again. + reply.header('X-RateLimit-Reset', info.resetMs.toString()); + } else 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)}`); + this.logger.warn(`rate limit information has unexpected type: ${JSON.stringify(info)}`); } } else if (err.kind === 'client') { reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_request", error_description="${err.message}"`); @@ -168,7 +175,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 +236,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 +311,7 @@ export class ApiCallService implements OnApplicationShutdown { path: string; } | null, request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>, + reply: FastifyReply, ) { const isSecure = user != null && token == null; @@ -339,19 +347,41 @@ export class ApiCallService implements OnApplicationShutdown { if (factor > 0) { // Rate limit - await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, 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 as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor) + .then(info => { + // We always want these headers, because clients need them for pacing. + // Conditional check in case we somehow revert to the old limiter, which does not return info. + if (info) { + // Number of seconds until the limit has fully reset. + reply.header('X-RateLimit-Clear', info.fullResetSec.toString()); + // Number of calls that can be made before being limited. + reply.header('X-RateLimit-Remaining', info.remaining.toString()); + + // Only forward the info object if it's blocked, otherwise we'll reject *all* requests + if (info.blocked) { + return info; + } + } + + return undefined; + }) + .catch(err => { + // The old limiter throws info instead of returning it. + if ('info' in err) { + return err.info as LimiterInfo; + } else { + throw err; + } + }); + + if (info) { + throw new ApiError({ + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', + httpStatusCode: 429, + }, info); + } } } |