From 47b3530af3a099456e19b313695f40fef294df38 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 14:01:31 +0100 Subject: [PATCH] fix(discord): prune idle rest route mappings --- .../discord/src/internal/rest-scheduler.ts | 39 ++++++++++++++++++- extensions/discord/src/internal/rest.test.ts | 8 ++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/extensions/discord/src/internal/rest-scheduler.ts b/extensions/discord/src/internal/rest-scheduler.ts index 2bc8c86505f..2ca4d395276 100644 --- a/extensions/discord/src/internal/rest-scheduler.ts +++ b/extensions/discord/src/internal/rest-scheduler.ts @@ -89,6 +89,7 @@ export class RestScheduler { return { globalRateLimitUntil: this.globalRateLimitUntil, activeBuckets: this.buckets.size, + routeBucketMappings: this.routeBuckets.size, buckets: Array.from(this.buckets.entries()).map(([key, bucket]) => ({ key, active: bucket.active, @@ -144,6 +145,34 @@ export class RestScheduler { return false; } + private isBucketRateLimited(bucket: BucketState, now = Date.now()): boolean { + return bucket.remaining === 0 && bucket.resetAt > now; + } + + private pruneRouteMapping(routeKey: string): void { + const bucketKey = this.routeBuckets.get(routeKey); + if (!bucketKey) { + return; + } + this.routeBuckets.delete(routeKey); + this.buckets.get(bucketKey)?.routeKeys.delete(routeKey); + } + + private pruneIdleRouteMappings( + bucketKey: string, + bucket: BucketState, + now = Date.now(), + ): void { + if (bucket.active > 0 || bucket.pending.length > 0 || this.isBucketRateLimited(bucket, now)) { + return; + } + for (const routeKey of Array.from(bucket.routeKeys)) { + if (this.routeBuckets.get(routeKey) === bucketKey) { + this.pruneRouteMapping(routeKey); + } + } + } + private shouldPruneIdleBucket(key: string): boolean { const mappedBucketKey = this.routeBuckets.get(key); return mappedBucketKey !== key && !this.hasBucketReference(key); @@ -267,7 +296,15 @@ export class RestScheduler { break; } if (bucket.pending.length === 0) { - if (bucket.active === 0 && this.shouldPruneIdleBucket(key)) { + if (bucket.active !== 0) { + continue; + } + if (this.isBucketRateLimited(bucket, now)) { + nextDelayMs = Math.min(nextDelayMs, bucket.resetAt - now); + continue; + } + this.pruneIdleRouteMappings(key, bucket, now); + if (this.shouldPruneIdleBucket(key)) { this.buckets.delete(key); } continue; diff --git a/extensions/discord/src/internal/rest.test.ts b/extensions/discord/src/internal/rest.test.ts index b03db6fe48f..f7373ad73fd 100644 --- a/extensions/discord/src/internal/rest.test.ts +++ b/extensions/discord/src/internal/rest.test.ts @@ -83,7 +83,7 @@ describe("RequestClient", () => { ]); }); - it("prunes idle route buckets after Discord bucket remapping", async () => { + it("prunes idle route buckets and mappings after Discord bucket remapping", async () => { const client = new RequestClient("test-token", { fetch: async () => new Response(JSON.stringify({ id: "first" }), { @@ -95,8 +95,9 @@ describe("RequestClient", () => { await expect(client.get("/channels/c1/messages")).resolves.toEqual({ id: "first" }); const metrics = client.getSchedulerMetrics(); - expect(metrics.activeBuckets).toBe(1); - expect(metrics.buckets.map((bucket) => bucket.key)).toEqual(["channel-messages:channels/c1"]); + expect(metrics.activeBuckets).toBe(0); + expect(metrics.routeBucketMappings).toBe(0); + expect(metrics.buckets).toEqual([]); }); it("waits for a learned bucket reset before dispatching the next request", async () => { @@ -135,6 +136,7 @@ describe("RequestClient", () => { const client = new RequestClient("test-token", { fetch: fetchSpy }); await expect(client.get("/channels/c1/messages")).resolves.toEqual({ id: "first" }); + expect(client.getSchedulerMetrics().routeBucketMappings).toBe(1); const second = client.get("/channels/c1/messages"); await Promise.resolve();