Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions products/tasks/backend/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
OAuthTokenError,
ProcessTaskError,
ProcessTaskTransientError,
RepositoryAccessError,
RepositoryCloneError,
SandboxNetworkPolicyError,
TaskInvalidStateError,
Expand Down Expand Up @@ -565,7 +566,7 @@


@StepTimer("environment_variables")
def _build_environment_variables(

Check warning on line 569 in products/tasks/backend/temporal/process_task/activities/provision_sandbox.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`_build_environment_variables` has cyclomatic complexity 14 (warn >10)

Check warning on line 569 in products/tasks/backend/temporal/process_task/activities/provision_sandbox.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`_build_environment_variables` has cyclomatic complexity 14 (warn >10)
ctx: TaskProcessingContext, task: Task, github_token: str, access_token: str
) -> dict[str, str]:
environment_variables = {
Expand Down Expand Up @@ -911,7 +912,7 @@

@asyncify
@posthoganalytics.scoped(fresh=True, capture_exceptions=False)
def _create_sandbox_for_repository(input: CreateSandboxForRepositoryInput) -> CreateSandboxForRepositoryOutput:

Check warning on line 915 in products/tasks/backend/temporal/process_task/activities/provision_sandbox.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`_create_sandbox_for_repository` has cyclomatic complexity 14 (warn >10)

Check warning on line 915 in products/tasks/backend/temporal/process_task/activities/provision_sandbox.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`_create_sandbox_for_repository` has cyclomatic complexity 14 (warn >10)
ctx = input.context
prepared = input.prepared

Expand Down Expand Up @@ -1229,6 +1230,18 @@
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(
Expand All @@ -1254,6 +1267,20 @@
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Loading