Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 3 commits into from
Jun 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions packages/backend/src/server/api/ApiCallService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ export class ApiCallService implements OnApplicationShutdown {
reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`);
}
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) {
statusCode = 500;
}
Expand Down Expand Up @@ -308,12 +318,17 @@ 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 => {
throw new ApiError({
message: 'Rate limit exceeded. Please try again later.',
code: 'RATE_LIMIT_EXCEEDED',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
httpStatusCode: 429,
});
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.');
}
});
}
}
Expand Down
36 changes: 19 additions & 17 deletions packages/backend/src/server/api/RateLimiterService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@ export class RateLimiterService {

@bindThis
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
const min = (): void => {
const min = new Promise<void>((ok, reject) => {
const minIntervalLimiter = new Limiter({
id: `${actor}:${limitation.key}:min`,
duration: limitation.minInterval! * factor,
Expand All @@ -46,25 +48,25 @@ export class RateLimiterService {

minIntervalLimiter.get((err, info) => {
if (err) {
return reject('ERR');
return reject({ code: 'ERR', info });
}

this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);

if (info.remaining === 0) {
reject('BRIEF_REQUEST_INTERVAL');
return reject({ code: 'BRIEF_REQUEST_INTERVAL', info });
} else {
if (hasLongTermLimit) {
max();
return max;
} else {
ok();
return ok();
}
}
});
};
});

// Long term limit
const max = (): void => {
const max = new Promise<void>((ok, reject) => {
const limiter = new Limiter({
id: `${actor}:${limitation.key}`,
duration: limitation.duration! * factor,
Expand All @@ -74,18 +76,18 @@ export class RateLimiterService {

limiter.get((err, info) => {
if (err) {
return reject('ERR');
return reject({ code: 'ERR', info });
}

this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);

if (info.remaining === 0) {
reject('RATE_LIMIT_EXCEEDED');
return reject({ code: 'RATE_LIMIT_EXCEEDED', info });
} else {
ok();
return ok();
}
});
};
});

const hasShortTermLimit = typeof limitation.minInterval === 'number';

Expand All @@ -94,12 +96,12 @@ export class RateLimiterService {
typeof limitation.max === 'number';

if (hasShortTermLimit) {
min();
return min;
} else if (hasLongTermLimit) {
max();
return max;
} else {
ok();
return Promise.resolve();
}
});
}
}
}
Loading