ci: add tests (#1141)

This commit is contained in:
Jiralite authored and GitHub committed 2026-06-23 04:41:46 +02:00
1 parent ad99de22bc
commit 545814abc1
152 files changed
+287 -532

No files matched your search

@@ -16,112 +16,8 @@ interface IRateLimitStore {
scan(pattern: string, count: number): Promise<Array<string>>;
}
interface InMemoryLeakyBucketState {
level: number;
updatedAtMs: number;
}
interface RateLimitServiceOptions {
globalWindowMs?: number;
getCurrentTimeMs?: () => number;
}
class InMemoryRateLimitStore implements IRateLimitStore {
private readonly leakyBucketState = new Map<string, InMemoryLeakyBucketState>();
constructor(private readonly getCurrentTimeMs: () => number = () => Date.now()) {}
async checkLeakyBucketLimit(key: string, limit: number, windowMs: number, cost: number): Promise<KVRateLimitResult> {
const nowMs = this.getCurrentTimeMs();
const capacity = Math.max(1, Math.floor(limit));
const leakWindowMs = Math.max(1, Math.floor(windowMs));
let bucket = this.leakyBucketState.get(key);
if (!bucket) {
bucket = {level: 0, updatedAtMs: nowMs};
this.leakyBucketState.set(key, bucket);
}
drainLeakyBucket(bucket, capacity, leakWindowMs, nowMs);
if (cost <= 0) {
const remaining = Math.max(0, Math.floor(capacity - bucket.level));
const resetAfterMs = leakyBucketResetAfterMs(bucket, capacity, leakWindowMs);
return createStoreResult(true, capacity, remaining, nowMs, resetAfterMs, 0);
}
if (cost !== 1) {
throw new Error(`Unsupported leaky bucket cost: ${cost}`);
}
if (bucket.level + cost > capacity) {
const retryAfterMs = leakyBucketRetryAfterMs(bucket, capacity, leakWindowMs, cost);
const resetAfterMs = leakyBucketResetAfterMs(bucket, capacity, leakWindowMs);
return createStoreResult(false, capacity, 0, nowMs, Math.max(resetAfterMs, retryAfterMs), retryAfterMs);
}
bucket.level += cost;
const remaining = Math.max(0, Math.floor(capacity - bucket.level));
const resetAfterMs = leakyBucketResetAfterMs(bucket, capacity, leakWindowMs);
return createStoreResult(true, capacity, remaining, nowMs, resetAfterMs, 0);
}
async del(...keys: Array<string>): Promise<number> {
let deleted = 0;
for (const key of keys) {
const deletedBucket = this.leakyBucketState.delete(key);
if (deletedBucket) {
deleted++;
}
}
return deleted;
}
async scan(pattern: string, count: number): Promise<Array<string>> {
const limit = Math.max(1, Math.floor(count));
const matcher = compileGlobMatcher(pattern);
const matches = new Set<string>();
for (const key of this.leakyBucketState.keys()) {
if (matcher(key)) {
matches.add(key);
if (matches.size >= limit) break;
}
}
return Array.from(matches);
}
}
function compileGlobMatcher(pattern: string): (key: string) => boolean {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*')
.replace(/\?/g, '.');
const regex = new RegExp(`^${escaped}$`);
return (key: string) => regex.test(key);
}
function drainLeakyBucket(bucket: InMemoryLeakyBucketState, capacity: number, windowMs: number, nowMs: number): void {
const elapsed = nowMs - bucket.updatedAtMs;
if (elapsed <= 0) {
return;
}
const leaked = (elapsed / windowMs) * capacity;
bucket.level = Math.max(0, bucket.level - leaked);
bucket.updatedAtMs = nowMs;
}
function leakyBucketRetryAfterMs(
bucket: InMemoryLeakyBucketState,
capacity: number,
windowMs: number,
cost: number,
): number {
const overflow = bucket.level + cost - capacity;
if (overflow <= 0) {
return 0;
}
return Math.max(1, Math.ceil(overflow / (capacity / windowMs)));
}
function leakyBucketResetAfterMs(bucket: InMemoryLeakyBucketState, capacity: number, windowMs: number): number {
if (bucket.level <= 0) {
return 0;
}
return Math.max(0, Math.ceil(bucket.level / (capacity / windowMs)));
}
function millisecondsToDecimalSeconds(milliseconds: number): number {
@@ -131,24 +27,6 @@ function millisecondsToDecimalSeconds(milliseconds: number): number {
return milliseconds / 1000;
}
function createStoreResult(
allowed: boolean,
limit: number,
remaining: number,
nowMs: number,
resetAfterMs: number,
retryAfterMs: number,
): KVRateLimitResult {
return {
allowed,
limit,
remaining,
resetAfterMs,
resetAtMs: nowMs + resetAfterMs,
retryAfterMs,
};
}
function createRateLimitResult(result: KVRateLimitResult, global?: boolean): RateLimitResult {
const resetAfterDecimal = millisecondsToDecimalSeconds(result.resetAfterMs);
const retryAfterDecimal = millisecondsToDecimalSeconds(result.retryAfterMs);
@@ -224,23 +102,3 @@ export class RateLimitService implements IRateLimitService {
return totalDeleted;
}
}
function createRateLimitService(
store: IRateLimitStore | null,
options: RateLimitServiceOptions = {},
): RateLimitService | null {
if (!store) {
return null;
}
return new RateLimitService(store, options);
}
export function createInMemoryRateLimitService(
enabled: boolean,
options: RateLimitServiceOptions = {},
): RateLimitService | null {
if (!enabled) {
return null;
}
return createRateLimitService(new InMemoryRateLimitStore(options.getCurrentTimeMs), options);
}