Skip to content

Commit 53f1db3

Browse files
authored
Improve GitHub authentication errors (#24502)
* Improve GitHub authentication errors * Add ddev authentication error changelog * Separate exception handler initialization * Use public MRO API for exception handlers * Centralize GitHub authentication error handling * Preserve GitHub rate limit and recovery handling * Route GitHub auth failures through central handling * Preserve GitHub auth compatibility and recovery context * Use public CLI exception helper name
1 parent b34b7aa commit 53f1db3

20 files changed

Lines changed: 605 additions & 43 deletions

ddev/changelog.d/24502.fixed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Show actionable guidance when GitHub rejects ddev's configured token.

ddev/src/ddev/cli/__init__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from ddev._version import __version__
1111
from ddev.cli import upgrade_check
12-
from ddev.cli.application import Application
12+
from ddev.cli.application import Application, DdevGroup
1313
from ddev.cli.ci import ci
1414
from ddev.cli.clean import clean
1515
from ddev.cli.config import config
@@ -27,9 +27,14 @@
2727
from ddev.plugin import specs
2828
from ddev.utils.ci import running_in_ci
2929
from ddev.utils.fs import Path
30+
from ddev.utils.github_errors import GitHubAuthenticationError
3031

3132

32-
@click.group(context_settings={'help_option_names': ['-h', '--help']}, invoke_without_command=True)
33+
def display_registered_exception(app: Application, error: Exception) -> None:
34+
app.display_error(str(error))
35+
36+
37+
@click.group(cls=DdevGroup, context_settings={'help_option_names': ['-h', '--help']}, invoke_without_command=True)
3338
@click.option('--core', '-c', is_flag=True, help='Work on `integrations-core`.')
3439
@click.option('--extras', '-e', is_flag=True, help='Work on `integrations-extras`.')
3540
@click.option('--marketplace', '-m', is_flag=True, help='Work on `marketplace`.')
@@ -93,6 +98,7 @@ def ddev(
9398
interactive = not running_in_ci()
9499

95100
app = Application(ctx.exit, verbose - quiet, color, interactive)
101+
app.register_exception_handler(GitHubAuthenticationError, display_registered_exception)
96102

97103
if config_file:
98104
app.config_file.path = Path(config_file).resolve()

ddev/src/ddev/cli/application.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
import logging
77
import os
88
from collections import defaultdict
9-
from collections.abc import Iterable
9+
from collections.abc import Callable, Iterable
1010
from functools import cached_property
1111
from typing import TYPE_CHECKING, cast
1212

13+
import click
14+
1315
from ddev.cli.terminal import Terminal
1416
from ddev.config.constants import AppEnvVars, ConfigEnvVars, VerbosityLevels
1517
from ddev.config.file import ConfigFileWithOverrides, RootConfig
@@ -20,7 +22,23 @@
2022
from ddev.utils.platform import Platform
2123

2224
if TYPE_CHECKING:
23-
from typing import Any, Callable, NoReturn
25+
from typing import Any, NoReturn
26+
27+
28+
type ExceptionHandler[E: Exception] = Callable[[Application, E], None]
29+
30+
31+
class DdevGroup(click.Group):
32+
"""Root command group that renders registered exceptions through the application."""
33+
34+
def invoke(self, ctx: click.Context) -> Any:
35+
try:
36+
return super().invoke(ctx)
37+
except Exception as error:
38+
app = ctx.obj
39+
if isinstance(app, Application) and app.handle_exception(error):
40+
ctx.exit(1)
41+
raise
2442

2543

2644
class AppLoggingHandler(logging.Handler):
@@ -57,6 +75,8 @@ def __init__(self, exit_func: Callable[[int], NoReturn], *args, **kwargs):
5775
# TODO: remove this when the old CLI is gone
5876
self.__config: dict[str, Any] = {}
5977

78+
self.__exception_handlers: dict[type[Exception], ExceptionHandler[Exception]] = {}
79+
6080
@property
6181
def config(self) -> RootConfig:
6282
return self.config_file.combined_model
@@ -116,6 +136,22 @@ def abort(self, text: str = '', code: int = 1, **kwargs: Any) -> NoReturn:
116136
self.display_error(text, **kwargs)
117137
self.__exit_func(code)
118138

139+
def register_exception_handler[E: Exception](
140+
self,
141+
exception_type: type[E],
142+
handler: ExceptionHandler[E],
143+
) -> None:
144+
"""Register an internal CLI handler for an exception type."""
145+
self.__exception_handlers[exception_type] = cast(ExceptionHandler[Exception], handler)
146+
147+
def handle_exception(self, error: Exception) -> bool:
148+
"""Render an exception with its nearest registered type."""
149+
for exception_type in type(error).mro():
150+
if handler := self.__exception_handlers.get(exception_type):
151+
handler(self, error)
152+
return True
153+
return False
154+
119155
def annotate_error(self, file: str, message: str, line: int = 1) -> None:
120156
"""Emit a GitHub Actions ``error`` workflow annotation; no-op outside CI."""
121157
self._emit_github_annotation(AnnotationLevel.ERROR, file, message, line)

ddev/src/ddev/cli/release/branch/create.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from httpx import HTTPError, HTTPStatusError
1313
from packaging.version import Version
1414

15+
from ddev.utils.github_errors import GitHubAuthenticationError
16+
1517
from .build_agent import BUILD_AGENT_YAML_PATH, ensure_build_agent_yaml_updated
1618

1719
if TYPE_CHECKING:
@@ -137,6 +139,11 @@ def bump_milestone(app: Application, branch_name: str) -> None:
137139
f'after cutting the `{branch_name}` release branch.',
138140
)
139141
app.display_success(f'Pull request created: {pr_url}')
142+
except GitHubAuthenticationError:
143+
app.display_warning(
144+
f'Failed to create the pull request. Please create one manually from `{bump_branch}` to `master`.'
145+
)
146+
raise
140147
except HTTPError as e:
141148
app.display_warning(
142149
f'Failed to create the pull request ({e}). Please create one manually from `{bump_branch}` to `master`.'

ddev/src/ddev/cli/release/branch/tag.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from packaging.version import Version
1010

1111
from ddev.utils.git import GitRepository
12+
from ddev.utils.github_errors import GitHubAuthenticationError
1213

1314
from .build_agent import BUILD_AGENT_YAML_PATH, find_build_agent_template_main_branch_matches
1415
from .create import BRANCH_NAME_REGEX
@@ -328,6 +329,8 @@ def _check_open_prs(app: Application, target_branch: str, skip_open_pr_check: bo
328329
httpx_logger.setLevel(logging.WARNING)
329330
try:
330331
prs = app.github.list_open_pull_requests_targeting_base(target_branch)
332+
except GitHubAuthenticationError:
333+
raise
331334
except Exception as e:
332335
click.secho(f'Warning: unable to check for open PRs: {e}', fg='yellow')
333336
return []
@@ -366,6 +369,12 @@ def _trigger_build_agent_yaml_update_workflow(app: Application, branch_name: str
366369
UPDATE_BUILD_AGENT_YAML_WORKFLOW_REF,
367370
{'branch': branch_name},
368371
)
372+
except GitHubAuthenticationError:
373+
app.display_warning(
374+
f'The tag was pushed, but `{UPDATE_BUILD_AGENT_YAML_WORKFLOW}` could not be triggered.\n'
375+
f'To trigger it manually: gh workflow run {UPDATE_BUILD_AGENT_YAML_WORKFLOW} -f branch={branch_name}'
376+
)
377+
raise
369378
except HTTPStatusError as e:
370379
app.display_warning(
371380
f'Warning: unable to trigger `{UPDATE_BUILD_AGENT_YAML_WORKFLOW}`: {e}\n'

ddev/src/ddev/cli/release/port_commit_workflow.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -323,8 +323,16 @@ def execute(self) -> None:
323323
import httpx
324324
from pydantic import ValidationError
325325

326+
from ddev.utils.github_errors import GitHubAuthenticationError
327+
326328
try:
327329
asyncio.run(self._create_pr())
330+
except GitHubAuthenticationError:
331+
if self.pr_url:
332+
self.app.display_warning(
333+
f'Pull request created at {self.pr_url} but labeling failed. Add the labels manually on the PR.'
334+
)
335+
raise
328336
except (httpx.HTTPError, ValidationError) as e:
329337
if self.pr_url:
330338
raise PortStepError(
@@ -410,6 +418,8 @@ def _resolve_pr_to_commit(app: Application, pr_number: int, *, dry_run: bool) ->
410418
import httpx
411419
from pydantic import ValidationError
412420

421+
from ddev.utils.github_errors import GitHubAuthenticationError
422+
413423
if not app.config.github.token:
414424
app.abort(
415425
'GitHub token required to resolve a PR reference. Set `github.token`, or pass the '
@@ -420,15 +430,12 @@ def _resolve_pr_to_commit(app: Application, pr_number: int, *, dry_run: bool) ->
420430
app.display_info(f'Resolving PR #{pr_number} via GitHub...')
421431
try:
422432
pr = asyncio.run(_fetch_pr(app.config.github.token, owner, repo, pr_number))
433+
except GitHubAuthenticationError:
434+
raise
423435
except httpx.HTTPStatusError as exc:
424436
status = exc.response.status_code
425437
if status == 404:
426438
raise _PRNotFound(str(pr_number)) from exc
427-
if status in (401, 403):
428-
app.abort(
429-
f'GitHub denied the request for PR #{pr_number} (HTTP {status}). '
430-
'Check that `github.token` is set and has `repo` scope.'
431-
)
432439
app.abort(f'Failed to fetch PR #{pr_number} from GitHub: {exc}.')
433440
except (httpx.HTTPError, ValidationError) as exc:
434441
app.abort(f'Failed to fetch PR #{pr_number} from GitHub: {exc}.')

ddev/src/ddev/cli/release/test_agent/monitoring.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from ddev.cli.release.test_agent.validation import REPO_NAME, REPO_OWNER
2222
from ddev.utils.github_async import async_github_client
23+
from ddev.utils.github_errors import GitHubAuthenticationError
2324

2425
if TYPE_CHECKING:
2526
from ddev.cli.release.test_agent.dispatch import DispatchedWorkflow
@@ -161,6 +162,8 @@ async def poll() -> MonitorState:
161162
nonlocal state
162163
try:
163164
state = await collect_monitor_state(client, workflows)
165+
except GitHubAuthenticationError:
166+
raise
164167
except httpx.HTTPError:
165168
pass
166169
return state

ddev/src/ddev/utils/github.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@
88
from time import time
99
from typing import TYPE_CHECKING, overload
1010

11+
from ddev.utils.github_errors import (
12+
GITHUB_AUTHENTICATION_STATUS_CODES,
13+
GitHubAuthenticationError,
14+
github_secondary_rate_limit_wait,
15+
)
16+
17+
MAX_SECONDARY_RATE_LIMIT_RETRIES = 2
18+
MAX_SECONDARY_RATE_LIMIT_WAIT_SECONDS = 3600
19+
1120
if TYPE_CHECKING:
1221
from typing import Any, Literal
1322

@@ -199,6 +208,8 @@ def get_changed_files_by_commit_sha(self, sha: str) -> list[str] | None:
199208

200209
try:
201210
response = self.__api_get(self.COMMIT_API.format(repo_id=self.repo_id, sha=sha))
211+
except GitHubAuthenticationError:
212+
raise
202213
except HTTPStatusError:
203214
return None
204215
return [file_data['filename'] for file_data in response.json().get('files', [])]
@@ -215,6 +226,8 @@ def get_pull_request_labels(self, pr_number: int) -> list[str] | None:
215226

216227
try:
217228
response = self.__api_get(self.PULL_REQUEST_API.format(repo_id=self.repo_id, pr_number=pr_number))
229+
except GitHubAuthenticationError:
230+
raise
218231
except HTTPStatusError:
219232
return None
220233
return [label['name'] for label in response.json().get('labels', [])]
@@ -305,16 +318,29 @@ def __api_get(self, *args, **kwargs):
305318
return self.__api_call('get', *args, **kwargs)
306319

307320
def __api_call(self, method, *args, **kwargs):
308-
from httpx import HTTPError
321+
from httpx import HTTPError, HTTPStatusError
309322

310323
retry_wait = 2
324+
secondary_rate_limit_retries = 0
311325
while True:
312326
try:
313327
response = getattr(self.client, method)(*args, auth=self.__auth, **kwargs)
314328

329+
secondary_rate_limit_wait = github_secondary_rate_limit_wait(response)
330+
if secondary_rate_limit_wait is not None:
331+
if (
332+
secondary_rate_limit_retries < MAX_SECONDARY_RATE_LIMIT_RETRIES
333+
and secondary_rate_limit_wait <= MAX_SECONDARY_RATE_LIMIT_WAIT_SECONDS
334+
):
335+
secondary_rate_limit_retries += 1
336+
self.__status.wait_for(
337+
secondary_rate_limit_wait + 1,
338+
context='GitHub API secondary rate limit reached',
339+
)
340+
continue
315341
# https://docs.github.com/en/rest/overview/resources-in-the-rest-api?apiVersion=2022-11-28#rate-limiting
316342
# https://docs.github.com/en/rest/guides/best-practices-for-integrators?apiVersion=2022-11-28#dealing-with-rate-limits
317-
if response.status_code == 403 and response.headers['X-RateLimit-Remaining'] == '0': # noqa: PLR2004
343+
elif response.status_code == 403 and response.headers.get('X-RateLimit-Remaining') == '0': # noqa: PLR2004
318344
self.__status.wait_for(
319345
float(response.headers['X-RateLimit-Reset']) - time() + 1,
320346
context='GitHub API rate limit reached',
@@ -325,5 +351,12 @@ def __api_call(self, method, *args, **kwargs):
325351
retry_wait *= 2
326352
continue
327353

328-
response.raise_for_status()
354+
try:
355+
response.raise_for_status()
356+
except HTTPStatusError as e:
357+
if github_secondary_rate_limit_wait(e.response) is not None:
358+
raise
359+
if e.response.status_code in GITHUB_AUTHENTICATION_STATUS_CODES:
360+
raise GitHubAuthenticationError.from_http_status_error(e) from e
361+
raise
329362
return response

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

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,18 @@
1010
import zipfile
1111
from collections.abc import AsyncIterator, Callable
1212
from contextlib import asynccontextmanager, suppress
13-
from dataclasses import dataclass
13+
from dataclasses import dataclass, replace
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.github_errors import (
21+
GITHUB_AUTHENTICATION_STATUS_CODES,
22+
GitHubAuthenticationError,
23+
github_secondary_rate_limit_wait,
24+
)
2025
from ddev.utils.rate_limiting import NULL_SNAPSHOT, BudgetSnapshot, InstrumentedAsyncLimiter
2126

2227
from .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

Comments
 (0)