Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ddev/changelog.d/24963.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add a retry strategy to the async GitHub client for failures that are not rate limiting.
47 changes: 47 additions & 0 deletions ddev/src/ddev/cli/ci/tests/dispatcher_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from pydantic import BaseModel, ConfigDict, Field

from ddev.cli.ci.tests.rate_limiting import RateLimiterFactoryConfig
from ddev.utils.github_async.retry import MUTATION_RETRY, SAFE_RETRY, RetryPolicies, RetryPolicy

if TYPE_CHECKING:
from ddev.repo.config import RepositoryConfig
Expand All @@ -26,6 +27,49 @@ class BatchingConfig(BaseModel):
allow_integration_splitting: bool = False


class RetryLimitsConfig(BaseModel):
"""Attempt and backoff limits for one class of GitHub request."""

model_config = ConfigDict(frozen=True, extra="forbid")

attempts: int = Field(default=3, ge=1)
# Bounds the whole ladder including backoff. None leaves attempts as the only stop condition.
timeout_seconds: float | None = Field(default=60.0, gt=0)
wait_initial_seconds: float = Field(default=0.5, gt=0)
wait_max_seconds: float = Field(default=10.0, gt=0)
wait_jitter_seconds: float = Field(default=1.0, ge=0)

def apply_to(self, policy: RetryPolicy) -> RetryPolicy:
"""`policy` with these limits, keeping the conditions it retries on."""
return policy.replace(
attempts=self.attempts,
timeout=self.timeout_seconds,
wait_initial=self.wait_initial_seconds,
wait_max=self.wait_max_seconds,
wait_jitter=self.wait_jitter_seconds,
)


class GitHubRetryConfig(BaseModel):
"""Retry limits for the GitHub client, read from `[dispatcher.github_retries]`.

Only how hard to try is configurable. What may be retried follows from whether an endpoint can be
replayed, so widening it from a config file would make a duplicate side effect a setting.
"""

model_config = ConfigDict(frozen=True, extra="forbid")

safe: RetryLimitsConfig = RetryLimitsConfig()
mutating: RetryLimitsConfig = RetryLimitsConfig(attempts=2)

def to_policies(self) -> RetryPolicies:
"""Build the client's policies: these limits over the built-in conditions."""
return RetryPolicies(
safe=self.safe.apply_to(SAFE_RETRY),
mutating=self.mutating.apply_to(MUTATION_RETRY),
)


class DispatcherConfig(BaseModel):
"""Per-repository Dispatcher configuration."""

Expand Down Expand Up @@ -67,6 +111,9 @@ class DispatcherConfig(BaseModel):
github_rate_limits: RateLimiterFactoryConfig = RateLimiterFactoryConfig()
"""Rate limiter tiers shared by every task, from `[dispatcher.github_rate_limits]`."""

github_retries: GitHubRetryConfig = GitHubRetryConfig()
"""How hard a failed GitHub request is retried, from `[dispatcher.github_retries]`."""

@classmethod
def from_repo_config(cls, repo_config: RepositoryConfig) -> DispatcherConfig:
"""Build a DispatcherConfig from the `/dispatcher` table of `.ddev/config.toml`."""
Expand Down
41 changes: 41 additions & 0 deletions ddev/src/ddev/utils/github_async/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,47 @@ Follow the block with an `Args:` section that describes each argument, and a
`Returns:` section describing the wrapped response. This keeps every method
traceable to its source contract and makes the public surface self-explanatory.

## Two retry layers, and which one owns a failure

Failures are retried in two places, and adding a retry to the wrong one is the mistake this section
exists to prevent.

- **Rate limiting** (`_rate_limited_request`, with `ddev.utils.rate_limiting`) owns 403 and 429
responses that the headers confirm are rate limiting. Re-acquiring the limiter *is* the backoff, so
there is no sleeping or arithmetic there. Never add a sleep or a wait to this layer.
- **The retry strategy** (`_request`, with `retry.py`) owns everything else: transport failures and
5xx. stamina executes it, so there is no backoff arithmetic here either.

The strategy wraps the rate-limit layer, never the reverse, so every attempt re-acquires the limiter
and waits out any pause the governor holds.

When adding an endpoint method, decide its default by whether the request can be **replayed**, not by
its verb:

- A GET takes the default, `retry` forwarded straight through.
- A mutation that is idempotent (sets given fields to given values, or is a no-op when repeated)
passes `retry=retry if retry is not None else self._retry_policies.safe` and says why in the
docstring. `update_check_run`, `update_issue_comment` and `add_labels_to_issue` are the examples.
- Any other mutation takes the default, which only retries failures that prove the request never
reached GitHub. Widening one of these is how you get a duplicate workflow run or a second comment.

What may be retried is not configurable; `[dispatcher.github_retries]` tunes attempts and backoff
only. A config file that could widen the conditions would make a duplicate side effect a setting.

The client reports its own retries through the logger passed to it, and stays quiet when there is
none. Separately, stamina installs a process-wide `on_retry` hook that logs every scheduled retry to
the `stamina` logger, so retries are visible even with no logger injected. That hook is global, and
disabling it would also silence the unrelated `stamina.retry` in `ddev/e2e/agent/docker.py`, so it is
left alone here rather than reached into from this package.

## Never follow a redirect

The client does not follow redirects, because the `Authorization` header would travel to whatever
host `Location` names. An unexpected 3xx raises `GitHubUnexpectedRedirectError` naming the endpoint,
and no policy may retry one. `download_artifact` is the single endpoint whose contract is a redirect;
it opts in with `expect_redirect=True` and reads `Location` itself. Do not enable `follow_redirects`
to make a failing request work.

## One method per API endpoint

Always keep exactly one method per API endpoint. Do not create convenience
Expand Down
12 changes: 12 additions & 0 deletions ddev/src/ddev/utils/github_async/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@
from .client import GitHubResponse as GitHubResponse
from .client import PaginationData as PaginationData
from .client import async_github_client as async_github_client
from .retry import DEFAULT_RETRY_POLICIES as DEFAULT_RETRY_POLICIES
from .retry import MUTATION_RETRY as MUTATION_RETRY
from .retry import NO_RETRY as NO_RETRY
from .retry import SAFE_RETRY as SAFE_RETRY
from .retry import RetryPolicies as RetryPolicies
from .retry import RetryPolicy as RetryPolicy

# Map of exported name -> submodule (relative to this package) that defines it.
MODULE_BY_NAME: dict[str, str] = {
Expand All @@ -41,6 +47,12 @@
'COMMENT_BODY_LIMIT': 'client',
'GitHubResponse': 'client',
'PaginationData': 'client',
'RetryPolicy': 'retry',
'RetryPolicies': 'retry',
'NO_RETRY': 'retry',
'SAFE_RETRY': 'retry',
'MUTATION_RETRY': 'retry',
'DEFAULT_RETRY_POLICIES': 'retry',
}


Expand Down
Loading
Loading