Source code for action0.github.retry
"""
GitHub-aware retries (:py:class:`GitHubRetryPolicy`).
The mechanics come from action0-client: wrap any backend in the retrying
variant of its execution model
(:py:class:`~action0.client.retry.RetryingSyncBackend` /
:py:class:`~action0.client.retry.RetryingAsyncBackend` /
:py:class:`~action0.client.retry.RetryingDeferredBackend`) and hand it
this policy::
backend = RetryingSyncBackend(RequestsBackend(), GitHubRetryPolicy())
client = GitHubClient(backend, token="ghp_...")
"""
from __future__ import annotations
import time
from collections.abc import Callable
from dataclasses import dataclass
from action0.client import RetryPolicy
from action0.req import Request
from action0.req import Response
RATE_LIMIT_REMAINING = "x-ratelimit-remaining"
"""The response header counting the requests left in the rate window."""
RATE_LIMIT_RESET = "x-ratelimit-reset"
"""The response header naming the rate window's end (epoch seconds)."""
def _is_rate_limited(response: Response) -> bool:
"""
Whether a response carries GitHub's rate-limit signals: a
``Retry-After`` header (secondary limits) or an exhausted
``x-ratelimit-remaining`` (primary limits).
:param response: the response to inspect
:return: whether GitHub says "rate limited"
"""
if response.headers.get("Retry-After") is not None:
return True
return response.headers.get(RATE_LIMIT_REMAINING) == "0"
[docs]
@dataclass(frozen=True)
class GitHubRetryPolicy(RetryPolicy):
"""
A :py:class:`~action0.client.retry.RetryPolicy` tuned to the GitHub
API. On top of the base behavior (transient 5xx/429 statuses,
``Retry-After`` honored, idempotent methods only — so ``CreateIssue``
and friends are never blindly repeated) it knows GitHub's rate limits:
- a **403 is retried only when it actually is a rate limit** (GitHub
also answers plain permission problems with 403): a ``Retry-After``
header or ``x-ratelimit-remaining: 0`` must be present,
- without a ``Retry-After``, an exhausted primary rate limit is waited
out until ``x-ratelimit-reset`` (GitHub's documented advice), capped
at :py:attr:`max_backoff` — raise it if you want to sit out whole
rate windows.
Example — an exhausted rate window resetting 90 seconds from now:
>>> policy = GitHubRetryPolicy(clock=lambda: 1_000_000.0)
>>> headers = {"x-ratelimit-remaining": "0", "x-ratelimit-reset": "1000090"}
>>> policy.delay_for(1, Response(403, headers=headers))
90.0
"""
max_backoff: float = 120.0
"""The wait cap in seconds — higher than the base default so GitHub's
"wait at least a minute" advice for secondary rate limits fits."""
clock: Callable[[], float] = time.time
"""The epoch-seconds clock ``x-ratelimit-reset`` waits are computed
against — injectable for tests."""
[docs]
def should_retry_response(self, request: Request, response: Response, attempt: int) -> bool:
"""
The base statuses, plus 403 when the response says "rate limited".
:param request: the request that was sent
:param response: the response that arrived
:param attempt: the (1-based) attempt that produced it
:return: whether to retry
"""
if super().should_retry_response(request, response, attempt):
return True
return (
response.status == 403
and attempt < self.attempts
and self.applies_to(request)
and _is_rate_limited(response)
)
[docs]
def delay_for(self, attempt: int, response: Response | None = None) -> float:
"""
The base delays (``Retry-After`` first, else jittered exponential
backoff), with one addition: an exhausted primary rate limit
without a ``Retry-After`` waits until ``x-ratelimit-reset``.
:param attempt: the attempt that just failed
:param response: the response that triggered the retry, if the
attempt produced one
:return: the wait in seconds, capped at :py:attr:`max_backoff`
"""
if (
response is not None
and self.respect_retry_after
# an explicit Retry-After wins — the base handles it
and response.headers.get("Retry-After") is None
):
reset_wait = self._reset_wait(response)
if reset_wait is not None:
return min(self.max_backoff, max(0.0, reset_wait))
return super().delay_for(attempt, response)
def _reset_wait(self, response: Response) -> float | None:
"""
The seconds until the rate window resets, if the response reports
an exhausted window with a parseable reset time.
:param response: the response to inspect
:return: the wait, or ``None`` if this is no exhausted window
"""
if response.headers.get(RATE_LIMIT_REMAINING) != "0":
return None
reset = response.headers.get(RATE_LIMIT_RESET)
if reset is None:
return None
try:
return float(reset) - self.clock()
except ValueError:
return None