Skip to content

Commit ffc87bc

Browse files
KrisBraunclaude
andcommitted
fix(github): suppress followed poll ticks during rate-limit backoff
The overlap guard keyed only on the sync cursor, which a page-1 rate limit never persists — so recurring ticks during a (possibly hourly) backoff kept re-hitting the limit and piling up runTask continuations. Add a followed_retry_at backoff marker the guard honours directly, independent of the cursor and the heartbeat staleness window. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bdb834b commit ffc87bc

2 files changed

Lines changed: 46 additions & 9 deletions

File tree

‎connectors/github/src/github.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,9 @@ describe("pollFollowed overlap guard", () => {
291291
set: async (k: string, v: unknown) => {
292292
store[k] = v;
293293
},
294+
clear: async (k: string) => {
295+
delete store[k];
296+
},
294297
// syncFollowedItems calls this first; returning null makes it a no-op
295298
// pass, so a single call is our proxy for "the drain actually ran".
296299
getAccountToken: async () => {
@@ -313,6 +316,26 @@ describe("pollFollowed overlap guard", () => {
313316
expect(calls()).toBe(0); // guarded: the drain (→ getAccountToken) never ran
314317
});
315318

319+
it("skips a recurring tick during a rate-limit backoff window (no cursor needed)", async () => {
320+
const { fake, calls } = guardFake({
321+
followed_retry_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
322+
});
323+
await GitHub.prototype.pollFollowed.call(fake);
324+
expect(calls()).toBe(0); // backoff marker suppresses the tick even with no cursor
325+
});
326+
327+
it("clears the backoff marker after a non-rate-limited pass", async () => {
328+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
329+
const { fake } = guardFake({
330+
// A leftover marker from an earlier backoff; the pass no longer rate-limits.
331+
followed_retry_at: new Date(Date.now() - 60 * 1000).toISOString(),
332+
});
333+
// Continuation bypasses the guard; syncFollowedItems returns done (no retryAt).
334+
await GitHub.prototype.pollFollowed.call(fake, true);
335+
warn.mockRestore();
336+
expect(await fake.get("followed_retry_at")).toBeNull(); // marker cleared
337+
});
338+
316339
it("does not skip a continuation call even with a cursor + fresh heartbeat", async () => {
317340
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
318341
const { fake, calls } = guardFake({

‎connectors/github/src/github.ts‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -446,12 +446,20 @@ export class GitHub extends Connector<GitHub> {
446446
* yet) — the recurring schedule re-drives the next incremental pass.
447447
*/
448448
async pollFollowed(isContinuation = false): Promise<void> {
449-
// A recurring tick must not start a second drain while a runTask
450-
// continuation chain of the same pass is still in flight — both would race
451-
// the shared `followed_sync_state` cursor. The chain refreshes a heartbeat;
452-
// if it's fresh AND a cursor exists, a drain is active, so skip. Staleness
453-
// (a crashed chain) lets a later recurring tick resume the pass.
449+
// A recurring tick must not start a redundant pass while one is already in
450+
// progress — a continuation chain (via runTask) is scheduled to carry it
451+
// forward. Two independent signals mark "in progress":
454452
if (!isContinuation) {
453+
// 1. A rate-limit backoff: a continuation is scheduled for `retryAt`, so
454+
// skip until then. This covers a page-1 rate limit, which leaves no
455+
// cursor behind, and outlives the heartbeat window on long (hourly)
456+
// primary limits.
457+
const retryAtStr = await this.get<string>("followed_retry_at");
458+
if (retryAtStr && Date.now() < new Date(retryAtStr).getTime()) return;
459+
460+
// 2. A multi-page drain: the chain refreshes a heartbeat, so a fresh
461+
// heartbeat with a live cursor means a drain is active. Staleness (a
462+
// crashed chain) lets a later recurring tick resume the pass.
455463
const heartbeat = await this.get<string>("followed_poll_heartbeat");
456464
const cursor = await this.get<{ page: number }>("followed_sync_state");
457465
if (
@@ -471,12 +479,18 @@ export class GitHub extends Connector<GitHub> {
471479
this as unknown as import("./followed-sync").FollowedSource,
472480
);
473481
if (retryAt) {
474-
// Rate-limited mid-pass: resume the same cursor after the limit resets.
482+
// Rate-limited: record the backoff (the guard above honours it) and
483+
// resume the same cursor after the limit resets.
484+
await this.set("followed_retry_at", retryAt.toISOString());
475485
const followedCallback = await this.createCallback(this.pollFollowed, true);
476486
await this.runTask(followedCallback, { runAt: retryAt });
477-
} else if (!done) {
478-
const followedCallback = await this.createCallback(this.pollFollowed, true);
479-
await this.runTask(followedCallback);
487+
} else {
488+
// Not rate-limited — clear any prior backoff marker.
489+
await this.clear("followed_retry_at");
490+
if (!done) {
491+
const followedCallback = await this.createCallback(this.pollFollowed, true);
492+
await this.runTask(followedCallback);
493+
}
480494
}
481495
}
482496

0 commit comments

Comments
 (0)