Skip to content

Commit b40aa0b

Browse files
authored
Add async GitHub client rate limiting for the Dispatcher (DataDog#24179)
* Add async GitHub client rate limiting for the Dispatcher (AI-6475) - Add InstrumentedAsyncLimiter wrapper around aiolimiter.AsyncLimiter that fires on_throttled/on_acquired callbacks for observability without touching aiolimiter internals - Add RateLimiterFactory (dispatcher-specific) that holds two shared limiter instances (default 360 req/hr, slow 120 req/hr) so all processors in a run compete for the same token buckets; validates default+slow <= total_max_rate at construction time - Wire rate_limiter into AsyncGitHubClient._request() and async_github_client() context manager - Add aiolimiter==1.2.1 dependency * Add changelog entry for DataDog#24179 * Update changelog entry for DataDog#24179 * Move RateLimiterConfig to dispatcher module and expose time_period * Convert rate limiter config objects to Pydantic models * Rename max_rate to hourly_max_rate and derive AsyncLimiter bucket size from it * Revert hourly_max_rate rename; normalize to req/hr only in factory validation * Address Codex review: keyword-only limiter arg and rate-limit artifact redirects - Make rate_limiter, default_timeout, and transport keyword-only on AsyncGitHubClient and the async_github_client factory. - Route the authenticated artifact-redirect GET through the rate-limited _request path so it counts against the shared limiter budget. * Add header-driven budget governor with a configurable pacing cap - BudgetGovernor reacts to GitHub's x-ratelimit/retry-after response headers, rationing requests as the shared budget runs low and hard-pausing on exhaustion or secondary limits; one governor is shared across both tiers. - max_wait_seconds caps the voluntary pacing interval (factory default 300s) so a request never paces itself out toward the full reset window; hard pauses are still honored in full. - The async client feeds each response's rate-limit headers to the governor. - Test cleanup: shared assert_blocks helper, clock/governor fixtures and a snapshot factory, parametrized cases, and added branch/coverage tests. * Address Codex review: commutative budget merges, drop underscore attributes - observe() now keeps the lowest remaining within a window and the longest retry_after pause, so out-of-order concurrent responses converge to the strictest constraint regardless of arrival order. - Drop leading underscores from InstrumentedAsyncLimiter and RateLimiterFactory instance attributes per the AGENTS.md naming rule. * Fold the monotonic-remaining rule into BudgetSnapshot.merged_with The within-window invariant (remaining is non-increasing until reset_at advances) is intrinsic to a rate-limit budget, so combining two observations enforces it directly rather than in a separate reconcile step. observe() drops back to a plain merged_with call and the monotonic tests move to that level. * Unify rate-limit observability into a single on_event callback Replace the four separate callbacks (on_budget_low, on_secondary_limit, on_throttled, on_acquired) with one on_event that receives a discriminated RateLimitEvent union (BUCKET, BUDGET, SECONDARY_LIMIT, PACING). Events fire on every acquire/observe/wait carrying a boolean or reason, so a single handler can emit continuous 0/1 metrics instead of sparse per-state callbacks. The factory shares one handler across the governor and both tier limiters. * Collapse reserve()/reserve_with_reason() into a single reserve() The bare reserve() had no production callers left (wait() is the only path that reserves, and it needs the reason), so merge the two into one reserve() that returns (target, reason). Callers take what they need. * Harden BudgetGovernor and InstrumentedAsyncLimiter after review - merged_with discards a stale, out-of-order response from an earlier window (smaller reset_at) instead of rolling budget state back, still coalescing retry_after. - Acquire the local bucket token AFTER the governor wait, not before, so the token is taken immediately before the request fires rather than being spent across a long pause. - wait() raises instead of silently proceeding if it fails to converge within MAX_WAIT_ITERATIONS, and emits a PacingEvent when a pause extends it mid-sleep. - Rename budget_target_with_reason -> claim_budget_target_with_reason to signal the pacing-cursor mutation; sample now() once in observe's retry_after branch. - Test suite: drop redundant/trivial tests, parametrize the merged_with overlay and wait() pacing-reason cases, assert behavior over internals, and add stale-window, concurrency, mid-sleep-extension, fail-loud, and acquire-order coverage. * Track the pause floor for extension events; drop a misleading concurrency test - wait() emits the mid-sleep SECONDARY_LIMIT PacingEvent only when the pause floor actually grows, instead of on every post-first iteration. This avoids a spurious event from monotonic-vs-wall-clock skew (asyncio.sleep uses the loop's monotonic clock while now() is wall-clock) and keeps the fail-loud path quiet. - Remove test_concurrent_waits_stagger_by_pacing_interval: the shared-advancing fake clock cannot model overlapping sleeps, so the test only passed because it ran sequentially; making it genuinely interleave collapses all finish times. The cursor invariant is structural (no await between reading and advancing next_slot) and covered by the sequential pacing test. - Nits: rename test to match its contract, use get_running_loop/create_task, and cancel the in-flight task in a finally. * Replace max_wait_seconds cap with a window-reset clamp max_wait_seconds was an arbitrary bound. The principled bound is the window itself: a rationed request never needs to wait past reset_at, because the budget refills then. claim_budget_target_with_reason now clamps at the boundary — once the pacing cursor reaches reset_at the window is spent, so overflow requests wait exactly until reset+buffer (EXHAUSTED) without advancing the cursor, so they all get the same target. Removes the config field, the governor parameter, and their tests; drops the pointless conditional advance in test_reserve_returns_now. * Forbid unknown keys in the rate limiter config models * Shorten the rate limiting changelog entry * Drop retry_after from the persisted budget and tighten rate-limit tests - BudgetSnapshot.merged_with no longer carries retry_after; it is a transient secondary-limit signal consumed only in observe, which makes the budget merge fully commutative. - Normalize snapshot construction in tests onto the make_snapshot factory and give it explicit optional fields instead of opaque **fields. - Rewrite the convergence test to assert an explicit expected state and name the failing permutation. - Docstring cleanup: state final behavior only, no development history. * Enforce the synchronous pacing-claim invariant with tests Add two guards for the "no await between reading and advancing next_slot" rule that reserve()'s inline comment relies on: - test_pacing_claim_path_is_synchronous asserts the claim path stays synchronous and, on failure, tells the editor what mechanism they owe in exchange. - test_reserve_assigns_distinct_slots_under_concurrent_claims gathers concurrent claims on the real event loop and asserts distinct, interval-spaced slots. Point the inline comment at both tests so it reads as enforced, not advisory. * Replace the wait iteration cap's flood role with a time budget The iteration count bounded nothing meaningful in time: one iteration costs anywhere from seconds to a retry_after of many minutes. Add an opt-in max_wait_seconds killswitch on BudgetGovernor that bounds the total time a single wait() may target, from when it starts, across both exhausted-window waits and accumulated secondary-limit extensions. - Raise RateLimitWaitAbandoned (subclasses TimeoutError) carrying waited_seconds and remaining_seconds; fail fast the moment the floor crosses the budget rather than sleeping into a doomed wait. - Emit a PacingEvent with the new PacingReason.ABANDONED before raising. - Default None keeps the current wait-indefinitely behavior. - Keep MAX_WAIT_ITERATIONS as the RuntimeError backstop for the no-progress bug (frozen clock) that a time budget cannot detect. - Fold in the single-current_floor cleanup in the wait loop. * Make GitHub client rate-limit protection the default and add rate-limit-aware retries Protection is now always on: constructing the async client without a rate_limiter builds a default one (a permissive bucket fronting a reactive BudgetGovernor) instead of running unprotected. Because octo-sts mints the token against one installation for the whole company, an unprotected client would degrade every other consumer. - Add github_async/defaults.py with default_github_rate_limiter and log_rate_limit_events (GitHub-shaped defaults; the rate_limiting module stays provider-agnostic). - Observe response headers before raise_for_status so a failed response's rate-limit signal is learned even when the caller swallows the exception. - Retry header-confirmed rate-limit responses (403/429) in _request; re-acquiring the limiter is the backoff, so there is no sleep or backoff math in the client. Transport errors and non-rate-limit statuses are never retried; RateLimitWaitAbandoned propagates. Bounded by max_rate_limit_retries (default 2). - Lower MAX_WAIT_ITERATIONS to 100 now that the time budget guards real floods.
1 parent d56a556 commit b40aa0b

11 files changed

Lines changed: 2003 additions & 15 deletions

File tree

ddev/changelog.d/24179.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add GitHub API rate limiting to the Dispatcher.

ddev/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ classifiers = [
2626
"Programming Language :: Python :: 3.13",
2727
]
2828
dependencies = [
29+
"aiolimiter==1.2.1",
2930
"anthropic>=0.18.0",
3031
"click~=8.1.6",
3132
"coverage",
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
"""Dispatcher-specific rate limiter factory."""
5+
6+
from __future__ import annotations
7+
8+
import logging
9+
from collections.abc import Callable
10+
11+
from aiolimiter import AsyncLimiter
12+
from pydantic import BaseModel, ConfigDict, Field, model_validator
13+
14+
from ddev.utils.rate_limiting import (
15+
RATE_LIMIT_TIME_PERIOD,
16+
BudgetGovernor,
17+
InstrumentedAsyncLimiter,
18+
RateLimitEvent,
19+
RateLimitEventType,
20+
)
21+
22+
SECONDS_PER_HOUR = 3600.0
23+
24+
25+
def event_logger(logger: logging.Logger) -> Callable[[RateLimitEvent], None]:
26+
"""Build an on_event handler that logs the informative rate-limit events at debug level."""
27+
28+
def handle(event: RateLimitEvent) -> None:
29+
if event.type is RateLimitEventType.BUDGET and event.is_low:
30+
logger.debug(
31+
"GitHub rate-limit budget low: %s/%s remaining, resets in %.1fs",
32+
event.remaining,
33+
event.limit,
34+
event.reset_in_seconds,
35+
)
36+
elif event.type is RateLimitEventType.SECONDARY_LIMIT:
37+
logger.debug("GitHub secondary rate limit hit, retry after %.1fs", event.retry_after_seconds)
38+
elif event.type is RateLimitEventType.BUCKET and event.throttled:
39+
logger.debug("%s rate limiter throttling request", event.name)
40+
41+
return handle
42+
43+
44+
class RateLimiterConfig(BaseModel):
45+
"""Rate limit configuration for a single limiter tier."""
46+
47+
model_config = ConfigDict(frozen=True, extra="forbid")
48+
49+
max_rate: float = Field(gt=0)
50+
time_period: float = Field(default=RATE_LIMIT_TIME_PERIOD, gt=0)
51+
52+
@property
53+
def hourly_rate(self) -> float:
54+
"""Effective rate expressed in requests per hour."""
55+
return self.max_rate / self.time_period * SECONDS_PER_HOUR
56+
57+
58+
class RateLimiterFactoryConfig(BaseModel):
59+
"""Configuration for the Dispatcher's two-tier rate limiter factory.
60+
61+
Each tier defines its own max_rate and time_period. The combined hourly rate of
62+
both tiers must not exceed total_hourly_max_rate.
63+
64+
Default values:
65+
- default: 360 req/hr — typical integrations
66+
- slow: 120 req/hr — integrations with long-running tests
67+
- total_hourly_max_rate: 1,500 req/hr — = 15k octo-sts budget / 10 max concurrent runs
68+
"""
69+
70+
model_config = ConfigDict(extra="forbid")
71+
72+
default: RateLimiterConfig = RateLimiterConfig(max_rate=360.0)
73+
slow: RateLimiterConfig = RateLimiterConfig(max_rate=120.0)
74+
total_hourly_max_rate: float = Field(default=1500.0, gt=0)
75+
slow_integrations: frozenset[str] = frozenset()
76+
reserve_fraction: float = Field(default=0.15, gt=0, le=1)
77+
budget_buffer_seconds: float = Field(default=1.0, ge=0)
78+
79+
@model_validator(mode="after")
80+
def validate_combined_rate(self) -> RateLimiterFactoryConfig:
81+
combined = self.default.hourly_rate + self.slow.hourly_rate
82+
if combined > self.total_hourly_max_rate:
83+
raise ValueError(
84+
f"default ({self.default.hourly_rate} req/hr) + slow ({self.slow.hourly_rate} req/hr) = "
85+
f"{combined} exceeds total_hourly_max_rate ({self.total_hourly_max_rate} req/hr)"
86+
)
87+
return self
88+
89+
90+
class RateLimiterFactory:
91+
"""Creates and vends rate limiters for the Dispatcher.
92+
93+
Holds exactly two shared InstrumentedAsyncLimiter instances — one for
94+
the default tier and one for the slow tier. All processors in a dispatcher
95+
run share the same factory, so they compete for the same token buckets and
96+
the per-run combined rate stays bounded.
97+
"""
98+
99+
def __init__(
100+
self,
101+
config: RateLimiterFactoryConfig | None = None,
102+
logger: logging.Logger | None = None,
103+
) -> None:
104+
cfg = config or RateLimiterFactoryConfig()
105+
self.slow_integrations = cfg.slow_integrations
106+
on_event = event_logger(logger) if logger else None
107+
budget_governor = BudgetGovernor(
108+
reserve_fraction=cfg.reserve_fraction,
109+
buffer_seconds=cfg.budget_buffer_seconds,
110+
on_event=on_event,
111+
)
112+
self.default = InstrumentedAsyncLimiter(
113+
AsyncLimiter(cfg.default.max_rate, cfg.default.time_period),
114+
on_event=on_event,
115+
budget_governor=budget_governor,
116+
name="default",
117+
)
118+
self.slow = InstrumentedAsyncLimiter(
119+
AsyncLimiter(cfg.slow.max_rate, cfg.slow.time_period),
120+
on_event=on_event,
121+
budget_governor=budget_governor,
122+
name="slow",
123+
)
124+
125+
def get_limiter(self, integrations: frozenset[str]) -> InstrumentedAsyncLimiter:
126+
"""Return the appropriate rate limiter for the given set of integrations.
127+
128+
Returns the slow limiter if any integration appears in the slow list,
129+
otherwise returns the default limiter.
130+
"""
131+
return self.slow if integrations & self.slow_integrations else self.default

ddev/src/ddev/utils/github_async/client.py

Lines changed: 148 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,18 @@
88
import io
99
import re
1010
import zipfile
11-
from collections.abc import AsyncIterator
12-
from contextlib import asynccontextmanager
11+
from collections.abc import AsyncIterator, Callable
12+
from contextlib import asynccontextmanager, suppress
1313
from dataclasses import dataclass
1414
from pathlib import Path
1515
from typing import Any, Literal, Self, overload
1616

1717
import httpx
1818
from pydantic import BaseModel, ConfigDict, Field
1919

20+
from ddev.utils.rate_limiting import NULL_SNAPSHOT, BudgetSnapshot, InstrumentedAsyncLimiter
21+
22+
from .defaults import default_github_rate_limiter
2023
from .models import (
2124
ArtifactsList,
2225
CheckRun,
@@ -75,24 +78,80 @@ class GitHubResponse[T](BaseModel):
7578
headers: dict[str, str] = Field(default_factory=dict)
7679

7780

81+
def parse_header[T](headers: httpx.Headers, key: str, cast: Callable[[str], T]) -> T | None:
82+
"""Return the header at *key* run through *cast*, or None if it is absent or unparseable."""
83+
raw = headers.get(key)
84+
if raw is None:
85+
return None
86+
with suppress(ValueError, TypeError):
87+
return cast(raw)
88+
return None
89+
90+
91+
def github_rate_limit_snapshot(headers: httpx.Headers) -> BudgetSnapshot | None:
92+
"""Parse GitHub's `x-ratelimit-*` / `retry-after` response headers into a BudgetSnapshot."""
93+
snapshot = BudgetSnapshot(
94+
limit=parse_header(headers, "x-ratelimit-limit", int),
95+
remaining=parse_header(headers, "x-ratelimit-remaining", int),
96+
reset_at=parse_header(headers, "x-ratelimit-reset", float),
97+
retry_after=parse_header(headers, "retry-after", lambda raw: float(int(raw))),
98+
)
99+
return snapshot if snapshot != NULL_SNAPSHOT else None
100+
101+
78102
class AsyncGitHubClient:
79103
"""
80104
Async HTTP client for the GitHub REST API.
81105
82106
Uses a shared httpx.AsyncClient for connection pooling. Call `aclose()` when
83107
finished to release resources, or use the `async_github_client` context manager.
108+
109+
Rate-limit protection is on by default: requests are paced and, when GitHub signals a
110+
rate-limit rejection, retried in reaction to the response headers. The governor supplies the
111+
backoff, so there is no sleeping or backoff arithmetic in this client. The default protection
112+
logs through the ``ddev.utils.github_async.defaults`` logger.
113+
114+
Args:
115+
token: GitHub token; must be non-empty.
116+
rate_limiter: Overrides the default rate limiter; it does not enable protection, which is
117+
already on. None builds the default (a permissive local bucket fronting a reactive
118+
BudgetGovernor). There is deliberately no way to disable protection: GitHub requires
119+
clients to honor ``retry-after``, and persistent violations risk the shared token being
120+
throttled harder or banned. Because octo-sts mints the token against one installation
121+
for the whole company, a single unprotected client instance degrades every other
122+
consumer of that token. Callers with special needs pass their own limiter; they do not
123+
turn protection off.
124+
default_timeout: Default per-request HTTP timeout in seconds. Bounds individual HTTP
125+
requests only; it does not bound governor waits. To bound total wait, pass a limiter
126+
whose governor sets ``max_wait_seconds``.
127+
max_rate_limit_retries: Extra attempts for a header-confirmed rate-limit response (403/429).
128+
Each retry is a full fresh acquisition (governor wait plus bucket token); the default of
129+
2 covers the common "hit a secondary limit once, wait, succeed" case plus one repeat.
130+
Only rate-limit responses are retried: transport errors and non-rate-limit statuses
131+
propagate immediately, and RateLimitWaitAbandoned (the governor's ``max_wait_seconds``
132+
killswitch) propagates to the caller.
133+
transport: Optional custom HTTPX transport (useful for testing with MockTransport).
84134
"""
85135

86136
def __init__(
87137
self,
88138
token: str,
139+
*,
140+
rate_limiter: InstrumentedAsyncLimiter | None = None,
89141
default_timeout: float = 30.0,
142+
max_rate_limit_retries: int = 2,
90143
transport: httpx.AsyncBaseTransport | None = None,
91144
) -> None:
92145
if not token:
93146
raise ValueError("GitHub token must not be empty.")
94147

148+
# A None limiter means "use the default protection," not "no protection." The local bucket
149+
# is deliberately permissive because the governor is the protection; with a healthy budget
150+
# and no secondary limits the governor adds zero wait, so this default is invisible to
151+
# well-behaved callers and engages only once GitHub has already signaled backpressure.
152+
self._rate_limiter = rate_limiter if rate_limiter is not None else default_github_rate_limiter()
95153
self._default_timeout = default_timeout
154+
self._max_rate_limit_retries = max_rate_limit_retries
96155
self._headers = {
97156
"Authorization": f"Bearer {token}",
98157
"X-GitHub-Api-Version": GITHUB_API_VERSION,
@@ -116,21 +175,71 @@ async def aclose(self) -> None:
116175
def _effective_timeout(self, timeout: float | None) -> float:
117176
return timeout if timeout is not None else self._default_timeout
118177

119-
async def _request(
178+
@staticmethod
179+
def _is_rate_limit_response(response: httpx.Response) -> bool:
180+
"""Whether *response* is a retryable rate-limit rejection, by GitHub's own discrimination rule.
181+
182+
A 403 is also used for plain permission denials, which waiting cannot fix; retrying one would
183+
sleep out a pause (up to a full window) and then fail identically. Only header-confirmed
184+
rate-limit responses are retryable.
185+
"""
186+
if response.status_code not in (403, 429):
187+
return False
188+
return "retry-after" in response.headers or response.headers.get("x-ratelimit-remaining") == "0"
189+
190+
async def _execute_request(
120191
self,
121192
method: str,
122193
endpoint: str,
123-
timeout: float | None = None,
194+
timeout: float,
124195
**kwargs: Any,
125196
) -> httpx.Response:
126-
effective_timeout = self._effective_timeout(timeout)
127197
try:
128-
response = await self._client.request(method, endpoint, timeout=effective_timeout, **kwargs)
198+
response = await self._client.request(method, endpoint, timeout=timeout, **kwargs)
129199
except httpx.TransportError as exc:
130200
raise type(exc)(f"{method} {endpoint}: {exc}") from exc
201+
# Observe before raise_for_status, never after: learning must not be gated on success. A
202+
# failed response's rate-limit headers arm the shared pause even if the caller swallows the
203+
# exception, so one request's 403 protects every other in-flight and future request in this
204+
# process.
205+
snapshot = github_rate_limit_snapshot(response.headers)
206+
if snapshot is not None:
207+
self._rate_limiter.observe(snapshot)
131208
response.raise_for_status()
132209
return response
133210

211+
async def _request(
212+
self,
213+
method: str,
214+
endpoint: str,
215+
timeout: float | None = None,
216+
**kwargs: Any,
217+
) -> httpx.Response:
218+
effective_timeout = self._effective_timeout(timeout)
219+
# Rate-limit-aware retry lives here, not in _execute_request: re-entering the limiter IS the
220+
# backoff. The failed response's headers were observed inside _execute_request before the
221+
# exception propagated, so the governor already holds the pause this very 403 armed;
222+
# re-acquiring waits it out exactly (retry-after plus buffer for secondary limits, until
223+
# reset for an exhausted window). Hence no sleeps or backoff math. A loop inside
224+
# _execute_request would be wrong: it would retry while still holding the acquisition,
225+
# without re-consulting the governor. Concurrent retries also serialize behind the same
226+
# shared pause instead of stampeding, which is what GitHub's secondary-limit guidance asks
227+
# for. RateLimitWaitAbandoned raised while (re-)acquiring propagates untouched from here: it
228+
# is the caller-configured killswitch, and counting it as an attempt would defeat it.
229+
for attempt in range(self._max_rate_limit_retries + 1):
230+
async with self._rate_limiter:
231+
try:
232+
return await self._execute_request(method, endpoint, effective_timeout, **kwargs)
233+
except httpx.HTTPStatusError as exc:
234+
# A rate-limit 403/429 is safe to retry for every endpoint, including
235+
# non-idempotent POSTs, precisely because GitHub rejected it without performing
236+
# the action. (Transport errors are never retried, and are not caught here: after
237+
# one we cannot know whether the action executed.) Give up on the last attempt or
238+
# on a non-rate-limit status, which waiting cannot fix.
239+
if attempt == self._max_rate_limit_retries or not self._is_rate_limit_response(exc.response):
240+
raise
241+
raise RuntimeError("unreachable: the retry loop always returns or raises") # pragma: no cover
242+
134243
async def _paginated_request(
135244
self,
136245
method: str,
@@ -598,13 +707,17 @@ async def _resolve_artifact_redirect(
598707
timeout: float | None = None,
599708
) -> str:
600709
"""Authenticated GET; return the unauthenticated signed URL from the 302 Location header."""
601-
effective_timeout = self._effective_timeout(timeout)
602-
redirect_response = await self._client.request(
603-
"GET",
604-
archive_download_url,
605-
timeout=effective_timeout,
606-
follow_redirects=False,
607-
)
710+
try:
711+
redirect_response = await self._request(
712+
"GET", archive_download_url, timeout=timeout, follow_redirects=False
713+
)
714+
except httpx.HTTPStatusError as exc:
715+
# httpx.raise_for_status() treats the expected 302 as an error since it isn't a 2xx;
716+
# recover the response from the exception so the redirect can still be inspected below.
717+
# The retry layer in _request is deliberately transparent here: a 302 is not a rate-limit
718+
# response by _is_rate_limit_response, so it is never retried and surfaces on the first
719+
# attempt exactly as before.
720+
redirect_response = exc.response
608721
if redirect_response.status_code != 302:
609722
redirect_response.raise_for_status()
610723
raise httpx.HTTPError(
@@ -678,21 +791,41 @@ async def download_artifact(
678791
@asynccontextmanager
679792
async def async_github_client(
680793
token: str,
794+
*,
795+
rate_limiter: InstrumentedAsyncLimiter | None = None,
681796
default_timeout: float = 30.0,
797+
max_rate_limit_retries: int = 2,
682798
transport: httpx.AsyncBaseTransport | None = None,
683799
) -> AsyncIterator[AsyncGitHubClient]:
684800
"""
685801
Async context manager that creates an AsyncGitHubClient and ensures it is closed on exit.
686802
803+
Rate-limit protection is on by default; the governor paces requests and supplies the backoff for
804+
retries. Header-confirmed rate-limit responses (403/429) are retried, transport errors and
805+
non-rate-limit statuses are not, and RateLimitWaitAbandoned propagates to the caller when the
806+
governor is configured with a wait budget. The default protection logs through the
807+
``ddev.utils.github_async.defaults`` logger.
808+
687809
Args:
688810
token: GitHub personal access token or app token.
689-
default_timeout: Default request timeout in seconds.
811+
rate_limiter: Overrides the default rate limiter; None uses the built-in default. This
812+
selects which limiter to use, it does not enable or disable protection (protection is
813+
always on).
814+
default_timeout: Default per-request HTTP timeout in seconds. Bounds individual HTTP
815+
requests only, not governor waits.
816+
max_rate_limit_retries: Extra attempts for a header-confirmed rate-limit response.
690817
transport: Optional custom HTTPX transport (useful for testing with MockTransport).
691818
692819
Yields:
693820
AsyncGitHubClient: A ready-to-use async GitHub client.
694821
"""
695-
client = AsyncGitHubClient(token=token, default_timeout=default_timeout, transport=transport)
822+
client = AsyncGitHubClient(
823+
token=token,
824+
rate_limiter=rate_limiter,
825+
default_timeout=default_timeout,
826+
max_rate_limit_retries=max_rate_limit_retries,
827+
transport=transport,
828+
)
696829
try:
697830
yield client
698831
finally:

0 commit comments

Comments
 (0)