Skip to content

fix: defer rate-limited board writes instead of failing the run - #65

Merged
BigLep merged 7 commits into
masterfrom
fix/graphql-secondary-rate-limit-retry
Oct 5, 2026
Merged

BigLep merged 7 commits into
masterfrom
fix/graphql-secondary-rate-limit-retry

Conversation

@BigLep

@BigLep BigLep commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The FOC Board Mechanical Rules workflow failed twice on 2026-10-03 (run 37081217551, run 37102189769) during the Cycle rollover: R-FC-013 had hundreds of Cycle updates queued, and after roughly 200 successful writes every later write got 403 Forbidden. That pattern points to GitHub's secondary rate limit, but the logs never captured the response body, so it isn't confirmed. This PR also makes the next failure explain itself.

An earlier version of this PR retried with sleeps inside graphql_query. That was replaced because the retries stacked with set_field_value_bulk's per-item fallback (one throttled batch could block for hours), blocked API server requests for minutes, and ignored 429s.

github-projects-client

  • graphql_query raises a typed GitHubRateLimitError (403 with a rate-limit message or exhausted quota, or 429) and includes GitHub's own error message in every HTTP error. It never sleeps or retries.
  • set_field_value_bulk waits 1s between mutation batches (GitHub's guidance for writes). On a rate limit it skips the per-item fallback, stops sending requests, and marks the remaining items rate_limited.
  • The API server returns 429 for GitHubRateLimitError; without a dedicated handler its GitHubAPIError handler would have returned 502.

foc-mechanical-rules

  • Rate-limited items are reported as deferred, which doesn't fail the run: the next hourly run retries them. error still fails the run, so problems that need a person (like a missing repo permission) stay loud.
  • A BatchedFieldRule base class now owns the batched write (group by value, bulk write, defer on rate limit, map results) that R-FC-012/013/014 and R-PR-010 each duplicated; rules only select and decide.
  • Follow-ups from fix: hint at permissions when R-PR-001 assignee mutation 404s #64: add_assignee returns the resulting assignees, and R-PR-001 now reports an error if GitHub accepted the request without assigning the author (previously this would have shown as "applied"). Its docstring no longer overclaims GitHub's 404 behavior, and the README documents deferred.

Follow-ups after review (9742135, 4ef0550, c40e67e)

  • GraphQL throttling that GitHub returns as HTTP 200 (RATE_LIMITED errors) is now detected (thanks @rjan90). Detection lives in one public is_rate_limited() covering every shape GitHub documents, including a 403 with Retry-After and the older "abuse detection" wording.
  • GitHub warns that continuing to send requests while rate limited may get the integration banned. The job's session now has a RateLimitBreaker that sends nothing after the first rate-limited response: remaining items are deferred, later rules are reported not run, and the run doesn't fail.
  • The CLI saves the mutation log even if a run crashes, so writes that already landed are never forgotten (R-FC-012 relies on that log).

Test plan

  • github-projects-client: uv run pytest -m "not integration" (102 passed), including rate-limit classification, batch pacing, stop-on-rate-limit with no fallback, non-throttle fallback still working, clear-mode batching, and the server's 429 mapping
  • foc-mechanical-rules: uv run pytest -m "not integration" (74 passed, including the breaker stopping requests, deferral after a trip, later rules not run, and the mutation log surviving a crash), including deferred results not failing the CLI, error still failing it, and the silent-non-assignment check
  • ruff lint and format via .githooks/pre-commit
  • Next Cycle rollover: confirm the run passes with any leftovers listed as deferred, and check the logged GitHub message to confirm the rate-limit diagnosis

🤖 Generated with Claude Code

R-FC-013's cycle rollover (202610-1 -> 202610-2) queued 211-416 Cycle
field updates in one run, batched into GraphQL mutations of 25 items
each. The first ~200 succeeded within seconds, then GitHub's secondary
rate limit kicked in and returned a flat 403 for the rest of the
batch, failing the whole hourly workflow run
(https://github.com/FilOzone/tpm-utils/actions/runs/37081217551,
.../runs/37102189769).

graphql_query had no retry/backoff at all; any non-2xx just raised
immediately. It now detects a rate-limited 403 (primary or secondary --
both come back as 403 with "rate limit" in the response body's
message, same signal the REST error handler in server/app.py already
uses) and retries up to 3 times, honoring GitHub's Retry-After header
with an exponential-backoff fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 4, 2026 14:45
@BigLep
BigLep requested a review from rjan90 as a code owner October 4, 2026 14:45
@FilOzzy FilOzzy added this to FOC Oct 4, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The fallback exhausts retries before GitHub’s documented cooldown and does not honor quota reset times.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Adds rate-limit retries to the shared GraphQL client used by board mutations.

Changes:

  • Retries rate-limited HTTP 403 responses up to three times.
  • Uses Retry-After or exponential backoff.
  • Adds unit coverage for retries and unchanged error handling.
File Description
github-projects-client/​tests/​test_api_unit.py Tests retry timing, exhaustion, and error handling.
github-projects-client/​github_projects_client/​api.py Adds rate-limit detection and bounded retries.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread github-projects-client/github_projects_client/api.py Outdated
BigLep and others added 2 commits October 4, 2026 08:05
The exponential backoff fallback exhausted all 3 retries in 7 seconds
(1s/2s/4s) when neither Retry-After nor rate-limit headers were
present. GitHub's GraphQL rate-limit docs require waiting at least a
minute for the secondary rate limit, and waiting until
X-RateLimit-Reset when the primary rate limit's quota is exhausted
(X-RateLimit-Remaining: 0). Floor and step the backoff at 60s, and
honor X-RateLimit-Reset when applicable, per
#65 (comment).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…sleeping

Retrying inside graphql_query compounded with set_field_value_bulk's
per-item fallback (a throttled 25-item batch could block for hours),
blocked API server requests for minutes, and ignored 429s. Instead:

- graphql_query raises a typed GitHubRateLimitError (403 with a rate
  limit message or exhausted quota, or 429) and includes GitHub's own
  message in every HTTP error, without retrying.
- set_field_value_bulk waits 1s between mutation batches, and on a rate
  limit skips the per-item fallback, stops sending requests, and marks
  the remaining items rate_limited.
- foc-mechanical-rules reports those items as "deferred", which does
  not fail the run; the next hourly run retries them.
- The API server maps GitHubRateLimitError to 429 (its GitHubAPIError
  handler would otherwise have returned 502).

Also follow-ups from #64: add_assignee now returns the resulting
assignees so R-PR-001 reports an error if GitHub accepted the request
without assigning the author, its docstring no longer overclaims
GitHub's 404 behavior, and README dashes are fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@BigLep BigLep changed the title fix: retry GraphQL mutations on GitHub's secondary rate limit fix: defer rate-limited board writes instead of failing the run Oct 4, 2026
@BigLep BigLep self-assigned this Oct 4, 2026
Comment thread github-projects-client/github_projects_client/api.py
BigLep and others added 3 commits October 5, 2026 14:03
GitHub reports GraphQL throttling as HTTP 200 with errors of type
RATE_LIMITED, which graphql_query surfaced as a generic GitHubAPIError.
set_field_value_bulk then fell back to per-item writes instead of
stopping and deferring (60 items made 63 requests). Raise
GitHubRateLimitError for that shape too, per review on #65.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitHub's docs warn that continuing to make requests while rate limited
may get the integration banned. The previous change only stopped inside
one set_field_value_bulk call; the remaining value groups and every
later rule kept querying. A rate limit outside the bulk write also
escaped uncaught, crashing the CLI before it saved the mutation log for
writes that had already landed (R-FC-012 relies on that log).

- github-projects-client: one public is_rate_limited() for every shape
  GitHub documents (429; 403 with exhausted quota, Retry-After, or a
  rate-limit/abuse message; GraphQL 200 whose errors report it), used
  by graphql_query and the server.
- foc-mechanical-rules: build_session mounts a RateLimitBreaker adapter
  that refuses to send anything after the first rate-limited response.
  Rule.run reports the tripping item and all later items as deferred
  and skips the batched flush; runner reports later rules as not run;
  a rate limit raised while resolving a bulk write defers that group.
- cli saves the mutation log even if the run raises.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cycle rules and R-PR-010 each had an identical mutate_pending
(group by value, bulk write, defer on rate limit, map results), so
every change had to be made twice. Move it to a BatchedFieldRule base
class keyed by board_field; rules now only select and decide.

Also stop is_rate_limited from decoding every successful response:
only GraphQL 200s can carry a rate limit, so REST pages of board items
passing through the session's breaker are no longer parsed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@BigLep
BigLep requested a balanced review from Copilot October 5, 2026 21:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Incomplete throttle handling can still abort the job or allow further requests after a rate limit.

Review effort: Balanced
Findings: 1 High severity · 2 Medium severity · 1 Low severity

Open (4)
Resolved since last review (1)

Comment thread foc-mechanical-rules/foc_mechanical_rules/runner.py
Comment thread github-projects-client/github_projects_client/mutations.py
Comment thread github-projects-client/tests/test_mutations_unit.py Outdated
Comment thread foc-mechanical-rules/foc_mechanical_rules/github_api.py Outdated

@rjan90 rjan90 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two suggestions/comments from Copilot should be addressed:

But apart from that it looks good to me!

- RateLimitBreaker raises GitHubRateLimitError on the response that
  trips it, so REST helpers (list_items' raise_for_status) no longer
  surface a plain HTTPError that crashed the run during select().
- The breaker logs GitHub's message and rate-limit headers once, so the
  workflow log shows which limit was hit even though items are deferred.
- set_field_value_bulk's old-value read lets rate limits propagate, so
  nothing is written after a throttled read in sessions without the
  breaker (e.g. the API server).
- The per-item fallback after a failed batch waits 1s before each
  write, per GitHub's guidance for mutative requests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@BigLep
BigLep requested a balanced review from Copilot October 5, 2026 21:43
@BigLep
BigLep merged commit 94bbf77 into master Oct 5, 2026
8 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Cross-layer throttling has unresolved correctness issues and still needs rollover validation.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (4)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Propagate write throttling metadata and return 429 when deferred

github-projects-client/​github_projects_client/​server/​app.py:153

Write-time throttling does not reach this handler: _execute_batch() catches GitHubRateLimitError and returns per-item failures, then server/routes/mutations.py:92-104 returns HTTP 200 and drops the new rate_limited flag. After successful preflight reads, even a throttled first mutation therefore provides no structured throttle or retry/reset metadata. Preserve that metadata in bulk results and handle it in the route: return 429 when all writes are deferred, while retaining successful results for partial completion. Add a route test that throttles the actual write; the current test only mocks the bulk call raising.

Comment on lines +263 to +265
except GitHubRateLimitError as exc:
results.extend(_rate_limited_results(batch, exc))
return exc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: 🎉 Done

Development

Successfully merging this pull request may close these issues.

4 participants