1010import zipfile
1111from collections .abc import AsyncIterator , Callable
1212from contextlib import asynccontextmanager , suppress
13- from dataclasses import dataclass
13+ from dataclasses import dataclass , replace
1414from pathlib import Path
1515from typing import Any , Literal , Self , overload
1616
1717import httpx
1818from pydantic import BaseModel , ConfigDict , Field
1919
20+ from ddev .utils .github_errors import (
21+ GITHUB_AUTHENTICATION_STATUS_CODES ,
22+ GitHubAuthenticationError ,
23+ github_secondary_rate_limit_wait ,
24+ )
2025from ddev .utils .rate_limiting import NULL_SNAPSHOT , BudgetSnapshot , InstrumentedAsyncLimiter
2126
2227from .defaults import default_github_rate_limiter
@@ -180,12 +185,15 @@ def _is_rate_limit_response(response: httpx.Response) -> bool:
180185 """Whether *response* is a retryable rate-limit rejection, by GitHub's own discrimination rule.
181186
182187 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.
188+ sleep out a pause (up to a full window) and then fail identically. Only responses confirmed
189+ by rate-limit headers or GitHub's secondary-limit message are retryable.
185190 """
186191 if response .status_code not in (403 , 429 ):
187192 return False
188- return "retry-after" in response .headers or response .headers .get ("x-ratelimit-remaining" ) == "0"
193+ return (
194+ github_secondary_rate_limit_wait (response ) is not None
195+ or response .headers .get ("x-ratelimit-remaining" ) == "0"
196+ )
189197
190198 async def _execute_request (
191199 self ,
@@ -203,6 +211,9 @@ async def _execute_request(
203211 # exception, so one request's 403 protects every other in-flight and future request in this
204212 # process.
205213 snapshot = github_rate_limit_snapshot (response .headers )
214+ secondary_rate_limit_wait = github_secondary_rate_limit_wait (response )
215+ if secondary_rate_limit_wait is not None :
216+ snapshot = replace (snapshot or NULL_SNAPSHOT , retry_after = secondary_rate_limit_wait )
206217 if snapshot is not None :
207218 self ._rate_limiter .observe (snapshot )
208219 response .raise_for_status ()
@@ -236,8 +247,14 @@ async def _request(
236247 # the action. (Transport errors are never retried, and are not caught here: after
237248 # one we cannot know whether the action executed.) Give up on the last attempt or
238249 # 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
250+ is_rate_limit_response = self ._is_rate_limit_response (exc .response )
251+ if is_rate_limit_response :
252+ if attempt == self ._max_rate_limit_retries :
253+ raise
254+ continue
255+ if exc .response .status_code in GITHUB_AUTHENTICATION_STATUS_CODES :
256+ raise GitHubAuthenticationError .from_http_status_error (exc ) from exc
257+ raise
241258 raise RuntimeError ("unreachable: the retry loop always returns or raises" ) # pragma: no cover
242259
243260 async def _paginated_request (
@@ -711,6 +728,8 @@ async def _resolve_artifact_redirect(
711728 redirect_response = await self ._request (
712729 "GET" , archive_download_url , timeout = timeout , follow_redirects = False
713730 )
731+ except GitHubAuthenticationError :
732+ raise
714733 except httpx .HTTPStatusError as exc :
715734 # httpx.raise_for_status() treats the expected 302 as an error since it isn't a 2xx;
716735 # recover the response from the exception so the redirect can still be inspected below.
0 commit comments