Skip to content

Resolve the app token again when retrying after a rate-limit wait - #549

Open
gaupon wants to merge 1 commit into
josegonzalez:masterfrom
gaupon:app-token-refresh-on-retry
Open

gaupon wants to merge 1 commit into
josegonzalez:masterfrom
gaupon:app-token-refresh-on-retry

Conversation

@gaupon

@gaupon gaupon commented Nov 15, 2026

Copy link
Copy Markdown

Follows up on #545.

With --app-id, a backup that uses up the rate limit stops with 401 after the rate-limit wait.

retrieve_data resolves the token through get_auth on every attempt, so pagination picks up a refreshed installation token. But the retries inside make_request_with_retry (rate-limit 403/429, 5xx, connection errors) resend the Request they were given, with the Authorization header from the first attempt. The installation token and the rate-limit window both start at the beginning of the run. When the limit is used up within that hour, the wait for x-ratelimit-reset ends right after the token expires. The retry gets 401, which is not retried, and the backup stops.

From a weekly full backup of an organization (--app-id, issues, pulls and their comments, timelines):

time (UTC)
installation token created 17:04:36 (expires 18:04:36)
HTTP 403 (rate limit exceeded), retrying in 924.0s 17:49:16
API Error: 401 Unauthorized, run stops 18:04:40

retrieve_graphql_data has the same problem, and resolves the token only once per call, so its own retry on a RATE_LIMITED error in the response body reuses it as well.

Changes

  • make_request_with_retry takes an optional rebuild_request callable and calls it to build each retry. Callers that do not pass one behave as before.
  • retrieve_data and retrieve_graphql_data pass one that resolves the token through get_auth / get_graphql_auth again. When the cached installation token is about to expire, that creates a new one.
  • retrieve_graphql_data resolves the token again for each attempt after the first, which covers the RATE_LIMITED retry.
  • Tests in tests/test_app_auth.py cover a token that is replaced during the wait for a REST rate-limit 403, a GraphQL rate-limit 403 and a GraphQL RATE_LIMITED error. All three fail without this change.

Testing

  • pytest: 213 passed. flake8 --ignore=E501,E203,W503 is clean.

  • Live, with --app-id against a private organization. A small wrapper around urlopen answered the first matching request with a rate-limit 403 (reset in 1 second, so the minimum 10 second wait). Before answering, it revoked the token that request carried (DELETE /installation/token, 204) and marked the cached token as expired, standing in for the hour passing. All other requests went to GitHub unchanged.

    0.68.0 this branch
    REST (full backup, wait on the issues listing) retry with the revoked token, 401, exit 1 retry with a new token, 200, backup completes
    GraphQL (--incremental-by-files with --issue-timeline, wait on the cross-reference check) retry with the revoked token, 401, Unable to check ... for new cross-references warning retry with a new token, 200, no warning

    The RATE_LIMITED path was not tested live, as I couldn't trigger it on demand.

Notes

  • request_as_app (the app JWT, valid for 9 minutes) is unchanged. It is only used to look up the installation and create tokens, so a long wait there is unlikely, and retrying with a new JWT would need a different change.
  • _construct_request logs Requesting ... each time it is called, so a REST retry now logs that line again.
  • Fix handling of temporary private forks #548 moves pull request comments to GraphQL, which then goes through the fixed path as well. The two branches merge without conflicts.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JefeUYfnfUPitmyF6hAYjm

make_request_with_retry resent the Request it was given, so a retry kept
the Authorization header from the first attempt. With --app-id the rate
limit window and the installation token both start at the beginning of
the run, so when the limit is used up within that hour, the wait for
x-ratelimit-reset ends right after the token expires and the retry fails
with 401, which stops the backup.

make_request_with_retry now takes an optional rebuild_request callable
and uses it to build each retry. retrieve_data and retrieve_graphql_data
pass one that resolves the token through get_auth again, which creates a
new installation token when the cached one is about to expire.

retrieve_graphql_data also resolved the token once per call, so its own
retry on a RATE_LIMITED error had the same problem. It now resolves the
token again for each attempt after the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JefeUYfnfUPitmyF6hAYjm
@Iamrodos

Copy link
Copy Markdown
Contributor

Looks good to merge. Thanks for the fix and contribution @gaupon.

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.

2 participants