feat(backend): report Retry-After if client hit rate limit (#13949)

* feat(backend): report `Retry-After` if client hit rate limit

* refactor(backend): fix lint error
This commit is contained in:
Kisaragi 2024-06-13 10:56:26 +09:00 committed by GitHub
parent c73d739bd6
commit dc3629e732
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 40 additions and 23 deletions

View file

@ -73,6 +73,16 @@ export class ApiCallService implements OnApplicationShutdown {
reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`); reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`);
} }
statusCode = statusCode ?? 403; statusCode = statusCode ?? 403;
} 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 (!statusCode) { } else if (!statusCode) {
statusCode = 500; statusCode = 500;
} }
@ -308,12 +318,17 @@ export class ApiCallService implements OnApplicationShutdown {
if (factor > 0) { if (factor > 0) {
// Rate limit // Rate limit
await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor).catch(err => { 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({ throw new ApiError({
message: 'Rate limit exceeded. Please try again later.', message: 'Rate limit exceeded. Please try again later.',
code: 'RATE_LIMIT_EXCEEDED', code: 'RATE_LIMIT_EXCEEDED',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
httpStatusCode: 429, httpStatusCode: 429,
}); }, err.info);
} else {
throw new TypeError('information must be a rate-limiter information.');
}
}); });
} }
} }

View file

@ -32,11 +32,13 @@ export class RateLimiterService {
@bindThis @bindThis
public limit(limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string, factor = 1) { public limit(limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string, factor = 1) {
return new Promise<void>((ok, reject) => { {
if (this.disabled) ok(); if (this.disabled) {
return Promise.resolve();
}
// Short-term limit // Short-term limit
const min = (): void => { const min = new Promise<void>((ok, reject) => {
const minIntervalLimiter = new Limiter({ const minIntervalLimiter = new Limiter({
id: `${actor}:${limitation.key}:min`, id: `${actor}:${limitation.key}:min`,
duration: limitation.minInterval! * factor, duration: limitation.minInterval! * factor,
@ -46,25 +48,25 @@ export class RateLimiterService {
minIntervalLimiter.get((err, info) => { minIntervalLimiter.get((err, info) => {
if (err) { if (err) {
return reject('ERR'); return reject({ code: 'ERR', info });
} }
this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`); this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
if (info.remaining === 0) { if (info.remaining === 0) {
reject('BRIEF_REQUEST_INTERVAL'); return reject({ code: 'BRIEF_REQUEST_INTERVAL', info });
} else { } else {
if (hasLongTermLimit) { if (hasLongTermLimit) {
max(); return max;
} else { } else {
ok(); return ok();
} }
} }
}); });
}; });
// Long term limit // Long term limit
const max = (): void => { const max = new Promise<void>((ok, reject) => {
const limiter = new Limiter({ const limiter = new Limiter({
id: `${actor}:${limitation.key}`, id: `${actor}:${limitation.key}`,
duration: limitation.duration! * factor, duration: limitation.duration! * factor,
@ -74,18 +76,18 @@ export class RateLimiterService {
limiter.get((err, info) => { limiter.get((err, info) => {
if (err) { if (err) {
return reject('ERR'); return reject({ code: 'ERR', info });
} }
this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`); this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
if (info.remaining === 0) { if (info.remaining === 0) {
reject('RATE_LIMIT_EXCEEDED'); return reject({ code: 'RATE_LIMIT_EXCEEDED', info });
} else { } else {
ok(); return ok();
} }
}); });
}; });
const hasShortTermLimit = typeof limitation.minInterval === 'number'; const hasShortTermLimit = typeof limitation.minInterval === 'number';
@ -94,12 +96,12 @@ export class RateLimiterService {
typeof limitation.max === 'number'; typeof limitation.max === 'number';
if (hasShortTermLimit) { if (hasShortTermLimit) {
min(); return min;
} else if (hasLongTermLimit) { } else if (hasLongTermLimit) {
max(); return max;
} else { } else {
ok(); return Promise.resolve();
} }
}); }
} }
} }