From b68b80f415a40f7ddecbee3bea9c242f18ee3867 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:18:07 +0000 Subject: [PATCH] fix(tasks): stop retrying a repository clone that github denies A clone that GitHub rejects for access (no usable token, or a token that does not cover the repository) now raises a non-retryable RepositoryAccessError. The error is not captured to error tracking and tells the person to check the GitHub app's repository access. Network and other clone failures keep the transient RepositoryCloneError and its retries. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 30c2fa20-55cd-4b80-b050-204eef23ea82 --- products/tasks/backend/exceptions.py | 12 ++++ .../activities/provision_sandbox.py | 27 +++++++++ .../tests/test_provision_sandbox.py | 60 +++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/products/tasks/backend/exceptions.py b/products/tasks/backend/exceptions.py index edd9940b8c17..b60c9740ef0c 100644 --- a/products/tasks/backend/exceptions.py +++ b/products/tasks/backend/exceptions.py @@ -266,6 +266,18 @@ class RepositoryCloneError(ProcessTaskTransientError): pass +class RepositoryAccessError(ProcessTaskFatalError, NonReportableError): + """GitHub refused to serve the repository with the credential the sandbox has. + + The run has no token, or its token does not cover the repository. A retry reuses the same + credential, so it cannot succeed. This is an expected customer setup state, so it is not + captured to error tracking. + """ + + def __init__(self, message: str, context: dict[str, Any]): + ProcessTaskError.__init__(self, message, context, None, capture=False, non_retryable=True) + + class RetryableRepositorySetupError(ProcessTaskTransientError): """Failed to setup repository (install dependencies, etc).""" diff --git a/products/tasks/backend/temporal/process_task/activities/provision_sandbox.py b/products/tasks/backend/temporal/process_task/activities/provision_sandbox.py index 560d096baa23..e38a1cee89ca 100644 --- a/products/tasks/backend/temporal/process_task/activities/provision_sandbox.py +++ b/products/tasks/backend/temporal/process_task/activities/provision_sandbox.py @@ -37,6 +37,7 @@ OAuthTokenError, ProcessTaskError, ProcessTaskTransientError, + RepositoryAccessError, RepositoryCloneError, SandboxNetworkPolicyError, TaskInvalidStateError, @@ -1229,6 +1230,18 @@ def clone_repository_in_sandbox(input: CloneRepositoryInSandboxInput) -> CloneRe blobless=blobless_clone, ) + if _is_repository_access_denied_clone_error(clone_result): + raise RepositoryAccessError( + f"Could not clone {input.repository}: GitHub denied access. Check that the PostHog " + "GitHub app is installed on this repository's account and can read this repository.", + { + "repository": input.repository, + "sandbox_id": input.sandbox_id, + "exit_code": clone_result.exit_code, + "has_github_token": bool(input.github_token), + }, + ) + if clone_result.exit_code != 0: error_output = clone_result.stderr or clone_result.stdout or clone_result.error or "No output captured" raise RepositoryCloneError( @@ -1254,6 +1267,20 @@ def clone_repository_in_sandbox(input: CloneRepositoryInSandboxInput) -> CloneRe return CloneRepositoryInSandboxOutput(clone_ms=clone_timer.elapsed_ms) +def _is_repository_access_denied_clone_error(result: ExecutionResult) -> bool: + if result.exit_code == 0: + return False + + # Git asks for a username when GitHub answers 401, which is how GitHub hides a private + # repository from a request without a usable token. + output = f"{result.stdout}\n{result.stderr}".casefold() + return ( + "could not read username" in output + or "authentication failed for" in output + or "remote: repository not found" in output + ) + + def _is_missing_remote_branch_clone_error(result: ExecutionResult) -> bool: if result.exit_code == 0: return False diff --git a/products/tasks/backend/temporal/process_task/activities/tests/test_provision_sandbox.py b/products/tasks/backend/temporal/process_task/activities/tests/test_provision_sandbox.py index ccf6d04e303c..4d6a64e263de 100644 --- a/products/tasks/backend/temporal/process_task/activities/tests/test_provision_sandbox.py +++ b/products/tasks/backend/temporal/process_task/activities/tests/test_provision_sandbox.py @@ -13,10 +13,13 @@ from asgiref.sync import async_to_sync +from posthog.temporal.common.posthog_client import is_expected_activity_failure + from products.tasks.backend.constants import SNAPSHOT_KIND_DIRECTORY, SNAPSHOT_KIND_FILESYSTEM from products.tasks.backend.exceptions import ( ComputeBillingLimitError, OrganizationExecutionError, + RepositoryAccessError, RepositoryCloneError, SandboxCleanupError, SandboxRateLimitedError, @@ -610,6 +613,63 @@ def test_clone_failure_records_failed_latency_and_captures_command_result(mocker assert str(capture_exception.call_args.args[0]) == "clone output" +@pytest.mark.parametrize( + "stderr, expected_error", + [ + ( + "Cloning into 'repo'...\nfatal: could not read Username for 'https://github.com': No such device or address\n", + RepositoryAccessError, + ), + ( + "Cloning into 'repo'...\nremote: Repository not found.\nfatal: repository 'https://github.com/acme/repo.git/' not found\n", + RepositoryAccessError, + ), + ( + "Cloning into 'repo'...\nfatal: unable to access 'https://github.com/acme/repo.git/': Could not resolve host: github.com\n", + RepositoryCloneError, + ), + ], +) +def test_clone_failure_stops_retries_only_when_github_denies_access( + mocker, activity_environment, stderr, expected_error +): + context = TaskProcessingContext( + task_id="task-id", + run_id="run-id", + team_id=1, + team_uuid="team-uuid", + organization_id="organization-id", + github_integration_id=123, + repository="acme/repo", + distinct_id="distinct-id", + state={}, + ) + sandbox = mocker.Mock() + sandbox.clone_repository.return_value = ExecutionResult(stdout="", stderr=stderr, exit_code=128, error=None) + mocker.patch( + "products.tasks.backend.temporal.process_task.activities.provision_sandbox.get_sandbox_class_for_sandbox_id", + return_value=mocker.Mock(get_by_id=mocker.Mock(return_value=sandbox)), + ) + capture_exception = mocker.patch("products.tasks.backend.exceptions.capture_exception") + + with pytest.raises(expected_error) as error: + async_to_sync(activity_environment.run)( + clone_repository_in_sandbox, + CloneRepositoryInSandboxInput( + context=context, + sandbox_id="sandbox-id", + repository="acme/repo", + github_token="", + shallow_clone=True, + ), + ) + + access_denied = expected_error is RepositoryAccessError + assert error.value.non_retryable is access_denied + assert is_expected_activity_failure(error.value) is access_denied + assert capture_exception.called is not access_denied + + def _prepared_for_create() -> PrepareSandboxForRepositoryOutput: return PrepareSandboxForRepositoryOutput( sandbox_name="sandbox-name",