diff --git a/docker/Dockerfile.runner b/docker/Dockerfile.runner index 0c8ef20..3339cca 100644 --- a/docker/Dockerfile.runner +++ b/docker/Dockerfile.runner @@ -18,26 +18,28 @@ ARG HELM_SHA256=0093eb572e3d2380f094df162ddb525e219249de88957afe24cfbb19632acd36 RUN apt-get update \ && apt-get install -y --no-install-recommends \ aspell aspell-en build-essential ca-certificates curl git git-lfs unzip \ + openjdk-17-jdk-headless \ libasound2 libatk-bridge2.0-0 libatk1.0-0 libcups2 libdbus-1-3 \ libgbm1 libgtk-3-0 libnspr4 libnss3 libxcomposite1 libxdamage1 \ libxfixes3 libxkbcommon0 libxrandr2 \ - && curl --fail --location --silent --show-error "$TEMURIN_8_URL" --output /tmp/temurin.tar.gz \ + && curl --fail --location --silent --show-error --retry 5 --retry-all-errors --retry-delay 2 "$TEMURIN_8_URL" --output /tmp/temurin.tar.gz \ && echo "$TEMURIN_8_SHA256 /tmp/temurin.tar.gz" | sha256sum --check - \ - && curl --fail --location --silent --show-error \ - "https://dlcdn.apache.org/maven/maven-3/$MAVEN_VERSION/binaries/apache-maven-$MAVEN_VERSION-bin.tar.gz" \ + && curl --fail --location --silent --show-error --retry 5 --retry-all-errors --retry-delay 2 \ + "https://mirrors.tuna.tsinghua.edu.cn/apache/maven/maven-3/$MAVEN_VERSION/binaries/apache-maven-$MAVEN_VERSION-bin.tar.gz" \ --output /tmp/maven.tar.gz \ && echo "$MAVEN_SHA512 /tmp/maven.tar.gz" | sha512sum --check - \ - && curl --fail --location --silent --show-error \ + && curl --fail --location --silent --show-error --retry 5 --retry-all-errors --retry-delay 2 \ "https://github.com/protocolbuffers/protobuf/releases/download/v$PROTOC_VERSION/protoc-$PROTOC_VERSION-linux-x86_64.zip" \ --output /tmp/protoc.zip \ && echo "$PROTOC_SHA256 /tmp/protoc.zip" | sha256sum --check - \ - && curl --fail --location --silent --show-error \ + && curl --fail --location --silent --show-error --retry 5 --retry-all-errors --retry-delay 2 \ "https://get.helm.sh/helm-v$HELM_VERSION-linux-amd64.tar.gz" \ --output /tmp/helm.tar.gz \ && echo "$HELM_SHA256 /tmp/helm.tar.gz" | sha256sum --check - \ && mkdir -p /opt/java/temurin-8u502-b07 /opt/maven/apache-maven-$MAVEN_VERSION \ /opt/protobuf/protoc-$PROTOC_VERSION \ && tar -xzf /tmp/temurin.tar.gz --strip-components=1 -C /opt/java/temurin-8u502-b07 \ + && ln -s /usr/lib/jvm/java-17-openjdk-amd64 /opt/java/temurin-17 \ && tar -xzf /tmp/maven.tar.gz --strip-components=1 -C /opt/maven/apache-maven-$MAVEN_VERSION \ && unzip -q /tmp/protoc.zip -d /opt/protobuf/protoc-$PROTOC_VERSION \ && tar -xzf /tmp/helm.tar.gz -C /tmp \ diff --git a/src/reposteward/github.py b/src/reposteward/github.py index d8aecf3..86af721 100644 --- a/src/reposteward/github.py +++ b/src/reposteward/github.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +import http.client import json import os import re @@ -144,23 +145,42 @@ def _request( } if self.token: headers["Authorization"] = f"Bearer {self.token}" - request = urllib.request.Request(url, data=body, headers=headers, method=method) - try: - response = urllib.request.urlopen(request, timeout=30) - payload_bytes = response.read() - except urllib.error.HTTPError as exc: - error_body = exc.read().decode("utf-8", errors="replace") - message = error_body + # GET 请求在瞬时网络错误(截断响应、连接重置等)下重试;POST/PATCH 可能非幂等,不重试 + attempts = 5 if method == "GET" else 1 + last_error: Exception | None = None + for attempt in range(attempts): + request = urllib.request.Request( + url, data=body, headers=headers, method=method + ) try: - message = json.loads(error_body).get("message", error_body) - except json.JSONDecodeError: - pass + response = urllib.request.urlopen(request, timeout=30) + payload_bytes = response.read() + last_error = None + break + except urllib.error.HTTPError as exc: + error_body = exc.read().decode("utf-8", errors="replace") + message = error_body + try: + message = json.loads(error_body).get("message", error_body) + except json.JSONDecodeError: + pass + raise GitHubError( + f"GitHub {method} {path} failed ({exc.code}): {message}", + status_code=exc.code, + ) from exc + except ( + urllib.error.URLError, + http.client.IncompleteRead, + ConnectionResetError, + TimeoutError, + ) as exc: + last_error = exc + if attempt + 1 < attempts: + time.sleep((attempt + 1) ** 2) + if last_error is not None: raise GitHubError( - f"GitHub {method} {path} failed ({exc.code}): {message}", - status_code=exc.code, - ) from exc - except urllib.error.URLError as exc: - raise GitHubError(f"GitHub {method} {path} failed: {exc.reason}") from exc + f"GitHub {method} {path} failed after retries: {last_error}" + ) from last_error if response.status not in set(expected): raise GitHubError( f"GitHub {method} {path} returned unexpected status {response.status}" diff --git a/src/reposteward/verifier.py b/src/reposteward/verifier.py index 1eb72e5..1602f36 100644 --- a/src/reposteward/verifier.py +++ b/src/reposteward/verifier.py @@ -539,6 +539,11 @@ def _run_container( "PNPM_HOME=/reposteward-env/pnpm-home", "-e", "GRADLE_USER_HOME=/reposteward-env/gradle", + # 容器以 host uid 运行且镜像内无对应 passwd 条目时,部分 JDK 会把 + # user.home 解析为 "?",导致 Maven 等工具把缓存写到工作区内随沙箱销毁。 + # 固定 user.home 到持久卷,保证 bootstrap 下载的依赖在断网 verify 阶段可用。 + "-e", + "JAVA_TOOL_OPTIONS=-Duser.home=/reposteward-env/home", "-v", f"{worktree.resolve()}:/workspace:rw", "-v", diff --git a/tests/test_github.py b/tests/test_github.py index f375503..2586a1e 100644 --- a/tests/test_github.py +++ b/tests/test_github.py @@ -1,5 +1,6 @@ from __future__ import annotations +import http.client import io import unittest import urllib.error @@ -968,6 +969,41 @@ def test_job_log_rejects_non_https_redirects(self) -> None: ): client.workflow_job_log("owner/repo", 20, max_bytes=12) + def test_get_request_retries_transient_transport_errors(self) -> None: + class FakeResponse(io.BytesIO): + def __init__(self, payload: bytes) -> None: + super().__init__(payload) + self.status = 200 + + client = GitHubClient(GitHubConfig(), token="test-token") + responses = [ + http.client.IncompleteRead(b'{"login": "pw', 5), + FakeResponse(b'{"login": "pwd11"}'), + ] + with ( + patch("urllib.request.urlopen", side_effect=responses) as urlopen_mock, + patch("time.sleep", return_value=None) as sleep_mock, + ): + payload, _ = client._request("GET", "/user") + + self.assertEqual(payload, {"login": "pwd11"}) + self.assertEqual(urlopen_mock.call_count, 2) + sleep_mock.assert_called_once_with(1) + + def test_post_request_does_not_retry_transient_transport_errors(self) -> None: + client = GitHubClient(GitHubConfig(), token="test-token") + with ( + patch( + "urllib.request.urlopen", + side_effect=http.client.IncompleteRead(b"{}", 1), + ), + patch("time.sleep") as sleep_mock, + self.assertRaisesRegex(GitHubError, "failed after retries"), + ): + client._request("POST", "/issues", data={"title": "x"}) + + sleep_mock.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_verifier.py b/tests/test_verifier.py index bb56fd5..596ed4e 100644 --- a/tests/test_verifier.py +++ b/tests/test_verifier.py @@ -549,6 +549,9 @@ def test_container_receives_shared_cache_and_read_only_git_mounts(self) -> None: self.assertIn(f"{environment.resolve()}:/reposteward-env:rw", command) self.assertIn(f"{git_dir.resolve()}:/reposteward-git:ro", command) self.assertIn("none", command) + # JVM user.home 固定到持久卷,避免无 passwd 条目时解析为 "?" 导致缓存丢失 + env_index = command.index("JAVA_TOOL_OPTIONS=-Duser.home=/reposteward-env/home") + self.assertEqual(command[env_index - 1], "-e") if __name__ == "__main__":