87 lines
2.9 KiB
TypeScript
87 lines
2.9 KiB
TypeScript
/*
|
|
* Copyright 2025 coze-dev Authors
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import { sleep } from './async';
|
|
|
|
type Fn<ARGS extends unknown[], Ret = unknown> = (...args: ARGS) => Ret;
|
|
|
|
/**
|
|
* 限流器,对于被限流的异步方法进行以下形式的限流:
|
|
* 1. 在 timeWindow 内的前 limit 个请求不做限制,立即发送
|
|
* 2. timeWindow 内超过 limit 个请求后,对每个请求依次添加 onLimitDelay 毫秒的延迟
|
|
*
|
|
* 注意是排队添加,形如 invoke: [1(0ms), 2(0ms), 3(0ms), 4(0ms)]; limit: [1(0ms), 2(0ms), 3(100ms), 4(200ms)]
|
|
*
|
|
* 另注:这个设计遭到了猛烈抨击,认为 debounce 可以代替掉,实现过于复杂,但是考虑:
|
|
* 1. 支持列表双向加载的拉取,简单使用 debounce 可能导致请求某侧丢失;添加延时可以保证不丢失请求
|
|
* 2. 列表拉取一旦出现死循环,可能导致恶性问题,如密集地对服务端接口的高频访问
|
|
*
|
|
* 以上场景通常不应出现,所以 limit 设计也只是对极端场景的兜底,上层 UI 错误理应得到妥善解决
|
|
* TODO: wlt - 补充 testcase
|
|
*/
|
|
export class RateLimit<ARGS extends unknown[], Ret> {
|
|
constructor(
|
|
private fn: Fn<ARGS, Promise<Ret>>,
|
|
private config: {
|
|
onLimitDelay: number;
|
|
limit: number;
|
|
timeWindow: number;
|
|
},
|
|
) {}
|
|
|
|
private records: number[] = [];
|
|
|
|
private getNewInvokeDelay(): number {
|
|
const { timeWindow, limit, onLimitDelay } = this.config;
|
|
const now = Date.now();
|
|
const windowEdge = now - timeWindow;
|
|
const idx = this.records.findIndex(t => t >= windowEdge);
|
|
if (idx < 0) {
|
|
return 0;
|
|
}
|
|
const lasts = this.records.slice(idx);
|
|
if (lasts.length < limit) {
|
|
return 0;
|
|
}
|
|
const last = lasts.at(-1);
|
|
if (!last) {
|
|
return 0;
|
|
}
|
|
return last + onLimitDelay - now;
|
|
}
|
|
|
|
private clearRecords() {
|
|
const { timeWindow } = this.config;
|
|
const now = Date.now();
|
|
const windowEdge = now - timeWindow;
|
|
const idx = this.records.findLastIndex(t => t < windowEdge);
|
|
if (idx >= 0) {
|
|
this.records = this.records.slice(idx + 1);
|
|
}
|
|
}
|
|
|
|
invoke = async (...args: ARGS): Promise<Ret> => {
|
|
const invokeDelay = this.getNewInvokeDelay();
|
|
const now = Date.now();
|
|
this.records.push(invokeDelay + now);
|
|
if (invokeDelay) {
|
|
await sleep(invokeDelay);
|
|
}
|
|
this.clearRecords();
|
|
return this.fn(...args);
|
|
};
|
|
}
|