Skip to content
Merged
1 change: 1 addition & 0 deletions ddev/changelog.d/24502.fixed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Show actionable guidance when GitHub rejects ddev's configured token.
10 changes: 8 additions & 2 deletions ddev/src/ddev/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from ddev._version import __version__
from ddev.cli import upgrade_check
from ddev.cli.application import Application
from ddev.cli.application import Application, DdevGroup
from ddev.cli.ci import ci
from ddev.cli.clean import clean
from ddev.cli.config import config
Expand All @@ -27,9 +27,14 @@
from ddev.plugin import specs
from ddev.utils.ci import running_in_ci
from ddev.utils.fs import Path
from ddev.utils.github_errors import GitHubAuthenticationError


@click.group(context_settings={'help_option_names': ['-h', '--help']}, invoke_without_command=True)
def _display_registered_exception(app: Application, error: Exception) -> None:
Comment thread
AAraKKe marked this conversation as resolved.
Outdated
app.display_error(str(error))


@click.group(cls=DdevGroup, context_settings={'help_option_names': ['-h', '--help']}, invoke_without_command=True)
@click.option('--core', '-c', is_flag=True, help='Work on `integrations-core`.')
@click.option('--extras', '-e', is_flag=True, help='Work on `integrations-extras`.')
@click.option('--marketplace', '-m', is_flag=True, help='Work on `marketplace`.')
Expand Down Expand Up @@ -93,6 +98,7 @@ def ddev(
interactive = not running_in_ci()

app = Application(ctx.exit, verbose - quiet, color, interactive)
app.register_exception_handler(GitHubAuthenticationError, _display_registered_exception)

if config_file:
app.config_file.path = Path(config_file).resolve()
Expand Down
40 changes: 38 additions & 2 deletions ddev/src/ddev/cli/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
import logging
import os
from collections import defaultdict
from collections.abc import Iterable
from collections.abc import Callable, Iterable
from functools import cached_property
from typing import TYPE_CHECKING, cast

import click

from ddev.cli.terminal import Terminal
from ddev.config.constants import AppEnvVars, ConfigEnvVars, VerbosityLevels
from ddev.config.file import ConfigFileWithOverrides, RootConfig
Expand All @@ -20,7 +22,23 @@
from ddev.utils.platform import Platform

if TYPE_CHECKING:
from typing import Any, Callable, NoReturn
from typing import Any, NoReturn


type ExceptionHandler = Callable[[Application, Exception], None]


class DdevGroup(click.Group):
"""Root command group that renders registered exceptions through the application."""

def invoke(self, ctx: click.Context) -> Any:
try:
return super().invoke(ctx)
except Exception as error:
app = ctx.obj
if isinstance(app, Application) and app.handle_exception(error):
ctx.exit(1)
raise


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

self.__exception_handlers: dict[type[Exception], ExceptionHandler] = {}

@property
def config(self) -> RootConfig:
return self.config_file.combined_model
Expand Down Expand Up @@ -116,6 +136,22 @@ def abort(self, text: str = '', code: int = 1, **kwargs: Any) -> NoReturn:
self.display_error(text, **kwargs)
self.__exit_func(code)

def register_exception_handler(
self,
exception_type: type[Exception],
handler: ExceptionHandler,
) -> None:
"""Register an internal CLI handler for an exception type."""
self.__exception_handlers[exception_type] = handler

def handle_exception(self, error: Exception) -> bool:
"""Render an exception with its nearest registered type."""
for exception_type in type(error).mro():
if handler := self.__exception_handlers.get(exception_type):
handler(self, error)
return True
return False

def annotate_error(self, file: str, message: str, line: int = 1) -> None:
"""Emit a GitHub Actions ``error`` workflow annotation; no-op outside CI."""
self._emit_github_annotation(AnnotationLevel.ERROR, file, message, line)
Expand Down
5 changes: 0 additions & 5 deletions ddev/src/ddev/cli/release/port_commit_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,11 +424,6 @@ def _resolve_pr_to_commit(app: Application, pr_number: int, *, dry_run: bool) ->
status = exc.response.status_code
if status == 404:
raise _PRNotFound(str(pr_number)) from exc
if status in (401, 403):
app.abort(
f'GitHub denied the request for PR #{pr_number} (HTTP {status}). '
'Check that `github.token` is set and has `repo` scope.'
)
app.abort(f'Failed to fetch PR #{pr_number} from GitHub: {exc}.')
except (httpx.HTTPError, ValidationError) as exc:
app.abort(f'Failed to fetch PR #{pr_number} from GitHub: {exc}.')
Expand Down
13 changes: 10 additions & 3 deletions ddev/src/ddev/utils/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from time import time
from typing import TYPE_CHECKING, overload

from ddev.utils.github_errors import GITHUB_AUTHENTICATION_STATUS_CODES, GitHubAuthenticationError

if TYPE_CHECKING:
from typing import Any, Literal

Expand Down Expand Up @@ -305,7 +307,7 @@ def __api_get(self, *args, **kwargs):
return self.__api_call('get', *args, **kwargs)

def __api_call(self, method, *args, **kwargs):
from httpx import HTTPError
from httpx import HTTPError, HTTPStatusError

retry_wait = 2
while True:
Expand All @@ -314,7 +316,7 @@ def __api_call(self, method, *args, **kwargs):

# https://docs.github.com/en/rest/overview/resources-in-the-rest-api?apiVersion=2022-11-28#rate-limiting
# https://docs.github.com/en/rest/guides/best-practices-for-integrators?apiVersion=2022-11-28#dealing-with-rate-limits
if response.status_code == 403 and response.headers['X-RateLimit-Remaining'] == '0': # noqa: PLR2004
if response.status_code == 403 and response.headers.get('X-RateLimit-Remaining') == '0': # noqa: PLR2004
self.__status.wait_for(
float(response.headers['X-RateLimit-Reset']) - time() + 1,
context='GitHub API rate limit reached',
Expand All @@ -325,5 +327,10 @@ def __api_call(self, method, *args, **kwargs):
retry_wait *= 2
continue

response.raise_for_status()
try:
response.raise_for_status()
except HTTPStatusError as e:
if e.response.status_code in GITHUB_AUTHENTICATION_STATUS_CODES:
raise GitHubAuthenticationError.from_http_status_error(e) from e
Comment on lines +359 to +360

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid labeling secondary rate limits as auth failures

This now maps every sync-client 403 that is not a primary-limit response (X-RateLimit-Remaining: 0) to a token/permission error. GitHub's REST rate-limit docs (https://docs.github.com/rest/using-the-rest-api/rate-limits-for-the-rest-api#exceeding-the-rate-limit) also describe secondary rate limits as 403/429 responses where retry-after or the error body can be the discriminator, so commands using GitHubManager can tell users to refresh github.token when they should wait/back off instead. Please exclude secondary-limit responses before raising GitHubAuthenticationError.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f5574ff. Secondary limits are now identified from either Retry-After or GitHub’s secondary-limit response message before authentication conversion. The classifier is shared by the sync and async clients; invalid delays fall back to 60 seconds, and synchronous secondary retries are bounded so persistent limits propagate as HTTPStatusError rather than token guidance.

raise
return response
11 changes: 9 additions & 2 deletions ddev/src/ddev/utils/github_async/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import httpx
from pydantic import BaseModel, ConfigDict, Field

from ddev.utils.github_errors import GITHUB_AUTHENTICATION_STATUS_CODES, GitHubAuthenticationError
from ddev.utils.rate_limiting import NULL_SNAPSHOT, BudgetSnapshot, InstrumentedAsyncLimiter

from .defaults import default_github_rate_limiter
Expand Down Expand Up @@ -236,8 +237,14 @@ async def _request(
# the action. (Transport errors are never retried, and are not caught here: after
# one we cannot know whether the action executed.) Give up on the last attempt or
# on a non-rate-limit status, which waiting cannot fix.
if attempt == self._max_rate_limit_retries or not self._is_rate_limit_response(exc.response):
raise
is_rate_limit_response = self._is_rate_limit_response(exc.response)
if is_rate_limit_response:
if attempt == self._max_rate_limit_retries:
raise
continue
if exc.response.status_code in GITHUB_AUTHENTICATION_STATUS_CODES:
raise GitHubAuthenticationError.from_http_status_error(exc) from exc
Comment on lines +255 to +256

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve partial-PR handling after auth conversion

When a side-effectful async call fails with 401/403, this conversion now raises GitHubAuthenticationError, which is not an httpx.HTTPError. Existing call sites such as CreatePullRequestStep.execute still catch (httpx.HTTPError, ValidationError) after self.pr_url is set so they can report “PR created but labeling failed”; with the real client, a label-permission 403 now bypasses that branch and the user loses the created PR URL/manual-label guidance. Update those call sites to catch the new type or otherwise preserve the partial-success handling.

Useful? React with 👍 / 👎.

@AAraKKe AAraKKe Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in bd54df1 after follow-up. GitHubAuthenticationError now bypasses the contextual manual-recovery catches in CreatePullRequestStep and the release branch create/tag workflows, so the centralized CLI handler tells the user to refresh the token. Non-authentication HTTP and validation failures still retain the existing partial-success/manual-recovery guidance. This deliberately treats authentication failures as recoverable configuration errors rather than sending the user directly to a manual workflow.

raise
raise RuntimeError("unreachable: the retry loop always returns or raises") # pragma: no cover

async def _paginated_request(
Expand Down
34 changes: 34 additions & 0 deletions ddev/src/ddev/utils/github_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# (C) Datadog, Inc. 2026-present
# All rights reserved
# Licensed under a 3-clause BSD style license (see LICENSE)
from __future__ import annotations

import httpx

GITHUB_AUTHENTICATION_STATUS_CODES = frozenset((401, 403))


def github_authentication_error_message(status_code: int, *, action: str = 'requested operation') -> str:
"""Return actionable guidance for a GitHub authentication failure."""
return (
f'GitHub denied the {action} (HTTP {status_code}). The configured token may be invalid, expired, '
'or missing required permissions. Run `ddev config set github.token` to configure a valid token.'
)


class GitHubAuthenticationError(Exception):
"""A GitHub HTTP failure caused by invalid authentication or insufficient permissions."""

def __init__(self, message: str, *, request: httpx.Request, response: httpx.Response) -> None:
super().__init__(message)
self.request = request
self.response = response

@classmethod
def from_http_status_error(cls, error: httpx.HTTPStatusError) -> GitHubAuthenticationError:
"""Build an authentication error while retaining the original HTTP context."""
return cls(
github_authentication_error_message(error.response.status_code),
request=error.request,
response=error.response,
)
31 changes: 17 additions & 14 deletions ddev/tests/cli/release/test_agent/test_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pytest
from pytest_mock import MockerFixture

from ddev.utils.github_errors import GitHubAuthenticationError
from tests.helpers.github_async import DEFAULT_DISPATCH_HTML_URL, FakeAsyncGitHubClient
from tests.helpers.runner import CliRunner

Expand All @@ -18,6 +19,15 @@
}


def github_authentication_error() -> GitHubAuthenticationError:
error = httpx.HTTPStatusError(
'forbidden',
request=httpx.Request('POST', 'https://api.github.com/'),
response=httpx.Response(403),
)
return GitHubAuthenticationError.from_http_status_error(error)


@pytest.fixture(autouse=True)
def _silence_git(mocker: MockerFixture) -> None:
"""Default git mocks: `fetch_target` succeeds and `git show` returns workflow yaml."""
Expand Down Expand Up @@ -293,32 +303,25 @@ def test_partial_dispatch_failure_surfaces_sibling_url(
failing_label: str,
) -> None:
"""When only one dispatch fails, the surviving side's URL must appear in the error message."""
err = httpx.HTTPStatusError(
'forbidden',
request=httpx.Request('POST', 'https://api.github.com/'),
response=httpx.Response(403),
)
err = github_authentication_error()
fake_async_github.mock_response('create_workflow_dispatch', err, workflow_id=failing_workflow)

result = ddev('release', 'test-agent', '--branch', '7.80.x', '--yes')

assert result.exit_code != 0, result.output
assert f'{failing_label} dispatch failed' in result.output
assert DEFAULT_DISPATCH_HTML_URL in result.output
assert 'ddev config set github.token' in result.output


def test_both_dispatches_fail_combine_messages(ddev: CliRunner, fake_async_github: FakeAsyncGitHubClient) -> None:
"""Both-fail must announce itself explicitly and surface both error reprs, not just substrings.

Asserting on `forbidden` appearing twice catches the previous regression where `add_note`
was used to attach the Windows error — `str(exc)` does not include notes, so the Windows
side was silently dropped from the abort message.
Asserting on the token guidance appearing twice catches the previous regression where
`add_note` was used to attach the Windows error — `str(exc)` does not include notes, so
the Windows side was silently dropped from the abort message.
"""
err = httpx.HTTPStatusError(
'forbidden',
request=httpx.Request('POST', 'https://api.github.com/'),
response=httpx.Response(403),
)
err = github_authentication_error()
fake_async_github.mock_response('create_workflow_dispatch', err)

result = ddev('release', 'test-agent', '--branch', '7.80.x', '--yes')
Expand All @@ -327,7 +330,7 @@ def test_both_dispatches_fail_combine_messages(ddev: CliRunner, fake_async_githu
assert 'Both dispatches failed' in result.output
assert 'Linux:' in result.output
assert 'Windows:' in result.output
assert result.output.count('forbidden') == 2
assert result.output.count('ddev config set github.token') == 2


def test_missing_github_token_aborts(ddev: CliRunner, mocker: MockerFixture, config_file) -> None:
Expand Down
36 changes: 36 additions & 0 deletions ddev/tests/cli/release/test_agent/test_monitoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
import importlib
from typing import Any

import httpx
import pytest

from ddev.cli.release.test_agent.dispatch import DispatchedWorkflow
from ddev.cli.release.test_agent.monitoring import (
JobState,
Expand All @@ -18,6 +21,7 @@
from ddev.cli.terminal import Terminal
from ddev.utils.github_async import GitHubResponse
from ddev.utils.github_async.models import WorkflowJob, WorkflowJobsList, WorkflowRun
from ddev.utils.github_errors import GitHubAuthenticationError
from tests.helpers.github_async import FakeAsyncGitHubClient

monitoring_module = importlib.import_module('ddev.cli.release.test_agent.monitoring')
Expand Down Expand Up @@ -253,6 +257,38 @@ async def test_monitor_workflows_does_not_raise_on_job_failure(fake_async_github
)


async def test_monitor_workflows_does_not_swallow_authentication_errors(
fake_async_github: FakeAsyncGitHubClient,
) -> None:
terminal = Terminal(verbosity=0, enable_color=False, interactive=False)
request = httpx.Request('GET', 'https://api.github.com/repos/DataDog/integrations-core/actions/runs/123')
error = httpx.HTTPStatusError('forbidden', request=request, response=httpx.Response(403))
fake_async_github.mock_response(
'get_workflow_run',
GitHubAuthenticationError.from_http_status_error(error),
)

async def unexpected_sleep(_: float) -> None:
raise AssertionError('authentication error was swallowed')

with pytest.raises(GitHubAuthenticationError):
await monitor_workflows(
terminal,
fake_async_github,
ref='7.80.x',
workflows=[
DispatchedWorkflow(
label='Linux',
workflow_id='test-agent.yml',
run_id=123,
html_url='https://github.com/DataDog/integrations-core/actions/runs/123',
)
],
poll_interval=0,
sleep=unexpected_sleep,
)


async def test_monitor_workflows_does_not_use_alternate_screen(
fake_async_github: FakeAsyncGitHubClient, monkeypatch
) -> None:
Expand Down
23 changes: 23 additions & 0 deletions ddev/tests/cli/release/test_port_commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pathlib import Path as StdPath
from unittest.mock import MagicMock, call

import httpx
import pytest
from pytest_mock import MockerFixture

Expand All @@ -24,6 +25,7 @@
)
from ddev.utils.git import GitCommit
from ddev.utils.github_async.models import PullRequest
from ddev.utils.github_errors import GitHubAuthenticationError
from tests.helpers.github_async import FakeAsyncGitHubClient
from tests.helpers.runner import CliRunner

Expand Down Expand Up @@ -846,6 +848,27 @@ def test_command_aborts_when_pr_input_has_no_token(
fake_async_github.assert_not_called('get_pull_request')


def test_command_reports_actionable_github_authentication_error(
ddev: CliRunner, mocker: MockerFixture, fake_async_github: FakeAsyncGitHubClient
) -> None:
error = httpx.HTTPStatusError(
'forbidden',
request=httpx.Request('GET', 'https://api.github.com/repos/DataDog/integrations-core/pulls/23703'),
response=httpx.Response(403),
)
fake_async_github.mock_response(
'get_pull_request',
GitHubAuthenticationError.from_http_status_error(error),
)
mocker.patch.dict('os.environ', {'DD_GITHUB_USER': 'alice'})

result = ddev('release', 'port-commit', '--dry-run', 'PR-23703')

assert result.exit_code == 1, result.output
assert 'GitHub denied the requested operation (HTTP 403)' in result.output
assert 'ddev config set github.token' in result.output


def test_command_falls_back_to_commit_on_pr_not_found(
ddev: CliRunner, mocker: MockerFixture, fake_async_github: FakeAsyncGitHubClient
) -> None:
Expand Down
Loading
Loading