Skip to content

feat: rate-limit-aware, resumable GitHub sync with webhook race guard (#24) - #36

Merged
chonilius merged 9 commits into
MergeFi:mainfrom
boluwacodes:fix/24-github-sync-rate-limit-backoff
Jul 22, 2026
Merged

feat: rate-limit-aware, resumable GitHub sync with webhook race guard (#24)#36
chonilius merged 9 commits into
MergeFi:mainfrom
boluwacodes:fix/24-github-sync-rate-limit-backoff

Conversation

@boluwacodes

Copy link
Copy Markdown
Contributor

Summary

Closes #24.

GithubSyncService built a plain Octokit client with no retry/backoff
and paginated via octokit.paginate(), which buffers every page
before returning. Investigated the concrete failure modes the issue
asked about:

  • A 429/403 mid-pagination throws with zero issues saved for that
    call
    (not "half-synced" — paginate() never returns anything until
    every page succeeds, so a failure on page N discards pages 1..N-1
    too), and propagates as an unhandled, opaque Octokit error.
  • No optimistic-concurrency guard on the issue upsert — and, on
    inspection, no webhook path even wrote to Issue's own fields at
    all yet (only read it, to find a linked bounty), so the "sync clobbers
    a webhook update" race had no real webhook-side write to guard against.

Changes

  • Retry/throttle plugins (octokit.provider.ts, new): Octokit
    configured with @octokit/plugin-retry + @octokit/plugin-throttling
    behind a GITHUB_OCTOKIT DI token. retry.doNotRetry explicitly adds
    429 on top of the plugin's default list so it doesn't race
    throttling's own retry-after-aware handling of the same error.
    onRateLimit/onSecondaryRateLimit share one handler that logs the
    hit and enforces a hard ceiling (GITHUB_MAX_RETRIES = 3) so a
    persistent outage can't retry forever.
  • Resumable sync: syncIssues switched from octokit.paginate() to
    octokit.paginate.iterator(), persisting each page as it's fetched.
    A failure now surfaces as GithubSyncInterruptedError (owner, repo,
    issuesSyncedBeforeFailure, cause) instead of an opaque error —
    "resumable failure with a clear signal," never a silent partial sync.
    Re-running is always safe: every write goes through the idempotent,
    guarded upsert below.
  • Webhook-vs-sync race guard: added Issue.githubUpdatedAt
    (migration included) and GithubSyncService.upsertIssueRecord, which
    skips a write if the stored githubUpdatedAt is already newer than
    the incoming data. Added a real issues webhook event handler
    (opened/edited/closed/reopened/...) that calls the same guarded
    upsert sync uses — this is what makes the ordering-independence
    guarantee real, rather than guarding a write path that didn't exist.
  • Rate-limit budget logging: syncRepository logs the core
    rate-limit budget before and after via octokit.rest.rateLimit.get(),
    best-effort (a failed lookup never blocks the sync).
  • Test infra fix: @octokit/rest/plugin-retry/plugin-throttling
    are ESM-only with no CJS build. Modern Node (20.19+/22.12+, incl. CI's
    Node 24) require()s them fine natively, but Jest's default config
    only transforms non-node_modules code, so anything importing the
    sync service failed to parse under ts-jest. Widened
    transformIgnorePatterns to cover @octokit/* and its ESM-only
    deps — test-tooling only, confirmed no production runtime impact by
    requiring the actual compiled dist/ output directly under Node.

Test plan

  • npm run lint — clean
  • npx tsc --noEmit — clean
  • npm run build — succeeds; manually verified the compiled
    dist/github/*.js load correctly under real Node (confirms the
    Jest transform fix is test-tooling-only, not masking a runtime issue)
  • npm test — 114/121 passing; the 7 failures are all in
    escrow-fk-integrity.integration.spec.ts, a pre-existing test
    requiring a live Postgres connection I don't have locally (CI
    provides one via services.postgres) — confirmed pre-existing
    and unrelated by reproducing the identical failure on main before
    any of my changes
  • New coverage: simulated 429 mid-pagination (partial progress
    persisted, GithubSyncInterruptedError thrown with accurate
    counts), ordering-independence for the concurrency guard in both
    directions, rate-limit logging before/after sync, and the retry/
    throttle handlers' ceiling behavior

…ergeFi#24)

Adds @octokit/plugin-retry and @octokit/plugin-throttling (compatible
with the already-installed @octokit/core@7 that @octokit/rest@22
resolves to) - the official plugins for backing off on transient
5xx/network errors and primary/secondary GitHub rate limits.

Both plugins, like @octokit/rest itself, ship ESM-only with no CJS
build. Jest's default config only transforms non-node_modules code, so
anything that imports the GitHub sync service (even transitively)
fails to parse under ts-jest with "Cannot use import statement outside
a module". Widens transformIgnorePatterns to also transform @octokit/*
and its ESM-only sub-dependencies (before-after-hook,
universal-user-agent). Real Node (20.19+/22.12+, and CI's Node 24)
already handles require()-ing these ESM packages natively, so this is
a test-tooling-only fix, not a production runtime concern.
createGithubOctokit builds an Octokit client with both plugins wired
in behind a GITHUB_OCTOKIT DI token (so tests can inject a mock
instead of a real Octokit):

- retry.doNotRetry explicitly adds 429 on top of the plugin's default
  list. plugin-retry would otherwise retry primary rate limits too,
  racing plugin-throttling's own retry-after-aware handling of the
  exact same error - 429 is deliberately left to throttling alone.
- throttle.onRateLimit/onSecondaryRateLimit share one handler factory
  (makeLimitHandler) that logs the hit and enforces a hard ceiling
  (GITHUB_MAX_RETRIES = 3) so a persistent outage or bad token can't
  retry forever.

Not wired into the module/service yet - that's the next commit.
…eFi#24)

Tracks GitHub's own updated_at for the issue, as of the last write
that touched it from either sync or a webhook. The next commit adds
the guard itself (GithubSyncService.upsertIssueRecord); this is just
the column both write paths will read/write.
Companion migration for the previous commit's entity change, matching
this repo's existing pattern of a reviewable migration file alongside
every schema change (see MergeFi#27's CreateIdempotencyKeys/
EscrowFkIntegrityAndSponsorId for precedent).
Addresses the core failure modes from MergeFi#24:

- syncIssues switches from octokit.paginate() (which buffers every
  page before returning) to octokit.paginate.iterator(), persisting
  each page as it's fetched. A rate-limit/network failure on page N no
  longer discards pages 1..N-1 - those are already durably saved.
- A failure now surfaces as GithubSyncInterruptedError, carrying owner/
  repo/issuesSyncedBeforeFailure/cause, instead of an opaque Octokit
  error - "resumable failure with a clear signal", not a silent
  partial sync. Re-running is always safe: every write goes through
  the idempotent, optimistic-concurrency-guarded upsertIssueRecord
  below, so nothing is duplicated and nothing is permanently skipped.
- upsertIssueRecord centralizes the per-issue upsert (previously
  inlined in syncIssues) and adds the guard itself: a write is skipped
  if the locally stored githubUpdatedAt is already newer than the
  incoming data. Public and reusable so the webhook path (next commit)
  can share it rather than duplicating the comparison.
- findRepositoryByGithubId is a small public lookup the webhook path
  needs to resolve a payload's repository.id to a local Repository row.
- logRateLimitStatus logs the core rate-limit budget before and after
  syncRepository, so a mid-sync interruption shows up as an obviously
  low "before" number rather than a mystery failure. Best-effort: a
  failed lookup never blocks the sync itself.

Now depends on GITHUB_OCTOKIT (previous commits) instead of
constructing its own bare Octokit client; wired into GithubModule's
providers here.
…ergeFi#24)

Adds an "issues" event handler (opened/edited/closed/reopened/...)
that resolves the payload's repository and calls
GithubSyncService.upsertIssueRecord - the exact same
optimistic-concurrency-guarded write path syncIssues uses. This is
what makes the ordering-independence guarantee real: whichever of
sync or a webhook has the freshest GitHub-side updated_at always wins,
regardless of which one runs, or commits, second. Before this, no
webhook path wrote to Issue's own fields at all, so the race MergeFi#24
describes had no real webhook-side write to guard.
…ndency (MergeFi#24)

Updates the existing spec's TestingModule with a GithubSyncService
mock (now a required constructor dependency) and adds coverage for the
three issues-event paths: delegates to upsertIssueRecord for a tracked
repository, ignores events for an untracked one without erroring, and
still marks the webhook event PROCESSED when the upsert itself
rejects the write as stale.
Tests makeLimitHandler directly rather than trying to simulate real
Octokit HTTP retry timing: retries while under GITHUB_MAX_RETRIES for
both primary and secondary limits, stops once the ceiling is reached
(so a persistent outage can't retry forever), and logs enough of the
request (method/url/retryAfter) to trace an interruption. Also smoke
-tests createGithubOctokit with and without a configured token.
…ging (MergeFi#24)

Matches MergeFi#24's acceptance criteria directly:

- A simulated 429 mid-pagination: the page fetched before the failure
  is durably persisted (issueRepo.save called for it), and syncIssues
  rejects with GithubSyncInterruptedError carrying owner/repo/
  issuesSyncedBeforeFailure and a message confirming re-running is
  safe - never a silently incomplete sync with no signal.
- upsertIssueRecord ordering-independence: a stale write is rejected
  whether it arrives as a "sync payload landing after a webhook" or a
  "delayed webhook retry landing after a sync" - same guard, same
  result, regardless of which caller runs second.
- syncRepository logs rate-limit budget both before and after, and a
  failed rate-limit lookup doesn't block the sync itself.
@vercel

vercel Bot commented Jul 22, 2026

Copy link
Copy Markdown

@boluwacodes is attempting to deploy a commit to the chonilius' projects Team on Vercel.

A member of the Team first needs to authorize it.

@chonilius
chonilius merged commit 1c6e2e0 into MergeFi:main Jul 22, 2026
1 of 2 checks passed
@chonilius

Copy link
Copy Markdown
Contributor

good fix

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GitHub sync has no rate-limit awareness or backoff against Octokit's REST API, risking hard failures during large org syncs

2 participants