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:
		| @@ -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; | ||||
| 		} | ||||
| @@ -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.'); | ||||
| 					} | ||||
| 				}); | ||||
| 			} | ||||
| 		} | ||||
|   | ||||
| @@ -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, | ||||
| @@ -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, | ||||
| @@ -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'; | ||||
|  | ||||
| @@ -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(); | ||||
| 			} | ||||
| 		}); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Kisaragi
					Kisaragi