Skip to content

Commit 641eaac

Browse files
Fix Pipenv matrix flake on 503/DNS transport blips (#419)
* Retry Pipenv cases on PyPI transport failures The Pipenv compatibility matrix downloads real wheels from PyPI and talks to the public patch service, but unlike the Bun, PDM and vlt harnesses it had no transport retry. A single PyPI 503 burst ("too many 503 error responses") turned a whole matrix leg red, and the run had to be re-run by hand: 15 failed legs across 10 runs in the last two weeks went green on re-run of the same commit. Re-run a failed case from a fresh case directory, at most three attempts, only when its error text or logs show a transport error (pip giving up on PyPI, connection errors, a CLI request error or a patch API 5xx). Functional failures are never retried. Earlier attempts' logs are kept under attempts/ and listed on the row. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrR5uqMbj9sJcU2Gpm9y9R * Treat macOS DNS failures as Pipenv transport errors Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrR5uqMbj9sJcU2Gpm9y9R --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6b8b355 commit 641eaac

3 files changed

Lines changed: 138 additions & 9 deletions

File tree

‎docs/testing/pipenv-compatibility.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ fixture dependency is `urllib3 1.26.18`, which has a public free-tier
107107
patch). Copy the binary out of `target/` first — a rebuild would swap it
108108
under the run. Concurrent invocations must use disjoint version/shape sets.
109109

110+
A case that fails with a transport error in its error text or logs (pip
111+
giving up on PyPI, e.g. `too many 503 error responses`, a connection error,
112+
or a CLI request error / patch API 5xx) is re-run from a fresh case
113+
directory, at most three attempts in total. The failed attempts' logs stay
114+
under `attempts/<case>/<n>/` and the final row lists them in
115+
`transportRetries`. Functional failures are never retried.
116+
110117
Per (release, shape, mode) the harness checks: the lock-only fresh checkout,
111118
`--dry-run` parity (hosted; the vendored preview is ledger-only by design and
112119
is recorded), an idempotent re-scan, the untouched Pipfile and `_meta`, the

‎scripts/backtest-pipenv.py‎

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,52 @@ def save(path, data):
154154

155155
DEFAULT_TIMEOUT = int(os.environ.get("BACKTEST_TIMEOUT", "900"))
156156

157+
# A transport failure, never a functional one: pip/Pipenv giving up on PyPI
158+
# ("too many 503 error responses", connection errors) or the CLI's own report
159+
# of a request error or a patch API 5xx.
160+
TRANSPORT_FAILURE = re.compile(
161+
r"too many 5\d\d error responses|Max retries exceeded with url|"
162+
r"NewConnectionError|ConnectTimeoutError|ReadTimeoutError|"
163+
r"Temporary failure in name resolution|nodename nor servname provided|Connection reset by peer|RemoteDisconnected|"
164+
r"error sending request for url \(|API request failed with status 5\d\d\b"
165+
)
166+
167+
168+
def has_transport_failure(case, payload):
169+
"""Whether a failed case's error text or any of its logs shows a transport failure."""
170+
if TRANSPORT_FAILURE.search(json.dumps(payload)):
171+
return True
172+
logs = sorted(case.glob("*.log*")) if case.is_dir() else []
173+
return any(TRANSPORT_FAILURE.search(log.read_text(errors="replace")) for log in logs if log.is_file())
174+
175+
176+
def retry_transport(run_case, job, case, root, attempts=3, sleep=time.sleep):
177+
"""("row"|"error", payload) for one case, re-run from a clean case dir while
178+
it fails for transport reasons. A failed attempt's logs are kept under
179+
<root>/attempts/<case>/<n>/ and listed on the final payload."""
180+
history = []
181+
for attempt in range(1, attempts + 1):
182+
try:
183+
kind, payload = "row", run_case(job)
184+
except Exception as e:
185+
version, shape, mode, invocation = job
186+
kind, payload = "error", {"pipenv": version, "shape": shape, "mode": mode, "invocation": invocation, "error": str(e)[-3000:], "trace": traceback.format_exc()[-1500:]}
187+
failed = kind == "error" or not payload.get("passed")
188+
if not failed or attempt == attempts or not has_transport_failure(case, payload):
189+
if history:
190+
payload["transportRetries"] = history
191+
if case.is_dir():
192+
save(case / "result.json", payload)
193+
return kind, payload
194+
evidence = root / "attempts" / case.name / str(attempt)
195+
evidence.mkdir(parents=True, exist_ok=True)
196+
for log in case.glob("*.log*") if case.is_dir() else []:
197+
if log.is_file():
198+
shutil.copy2(log, evidence / log.name)
199+
history.append({"attempt": attempt, "evidence": evidence.relative_to(root).as_posix(), "error": (payload.get("error") or "")[-300:], "failedChecks": [k for k, ok in payload.get("checks", {}).items() if not ok]})
200+
print(f"{case.name}: transport failure; retrying fresh case ({attempt}/{attempts})", flush=True)
201+
sleep(10 * attempt)
202+
157203

158204
class Run:
159205
"""Run a command in its own process group, capture output, write a log.
@@ -680,11 +726,15 @@ def source_keys(text):
680726
return sorted({k for _, _, e in lock_entries(text) if isinstance(e, dict) for k in ("file", "path") if k in e})
681727

682728
# -------------------------------------------------------------- one case
729+
def case_dir(job):
730+
version, shape, mode, invocation = job
731+
suffix = "" if invocation == "in-dir" else "-" + invocation
732+
return root / "captures" / f"{version}-{shape}-{mode}{suffix}"
733+
683734
def backtest(job):
684735
"""Run one case; persist its row (or error) as <case>/result.json."""
685736
version, shape, mode, invocation = job
686-
suffix = "" if invocation == "in-dir" else "-" + invocation
687-
case = root / "captures" / f"{version}-{shape}-{mode}{suffix}"
737+
case = case_dir(job)
688738
try:
689739
row = backtest_case(job)
690740
except Exception as e:
@@ -699,8 +749,7 @@ def backtest_case(job):
699749
legacy = is_legacy(version)
700750
major = major_of(version)
701751
tool = tool_dir(version)
702-
suffix = "" if invocation == "in-dir" else "-" + invocation
703-
case = root / "captures" / f"{version}-{shape}-{mode}{suffix}"
752+
case = case_dir(job)
704753
if case.exists():
705754
shutil.rmtree(case)
706755
case.mkdir(parents=True)
@@ -1277,11 +1326,8 @@ def run_group(key):
12771326
out = []
12781327
for m, inv in groups[key]:
12791328
job = (v, s, m, inv)
1280-
try:
1281-
row = backtest(job)
1282-
out.append(("row", job, row))
1283-
except Exception as e:
1284-
out.append(("error", job, {"pipenv": v, "shape": s, "mode": m, "invocation": inv, "error": str(e)[-3000:], "trace": traceback.format_exc()[-1500:]}))
1329+
kind, payload = retry_transport(backtest, job, case_dir(job), root)
1330+
out.append((kind, job, payload))
12851331
yield out[-1]
12861332

12871333
def flush():

‎scripts/tests/test_backtest_harnesses.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,82 @@ def test_checkout_drops_manifest_node_modules_and_old_output(self):
148148
'packages/c/package.json'])
149149

150150

151+
class PipenvTransportRetryTests(unittest.TestCase):
152+
JOB = ("2023.12.1", "direct", "hosted", "in-dir")
153+
PYPI_503 = ("pip._vendor.urllib3.exceptions.MaxRetryError: HTTPSConnectionPool(host='files.pythonhosted.org', "
154+
"port=443): Max retries exceeded with url: /packages/urllib3-1.26.18-py2.py3-none-any.whl "
155+
"(Caused by ResponseError('too many 503 error responses'))")
156+
157+
def drive(self, outcomes, root):
158+
"""Drive retry_transport with one scripted outcome per attempt."""
159+
case = root / "captures" / "2023.12.1-direct-hosted"
160+
calls = []
161+
162+
def run_case(_job):
163+
outcome = outcomes[len(calls)]
164+
calls.append(outcome)
165+
if case.exists():
166+
pipenv.shutil.rmtree(case)
167+
case.mkdir(parents=True)
168+
(case / "install.log").write_text(outcome.get("log", ""))
169+
if "raise" in outcome:
170+
raise RuntimeError(outcome["raise"])
171+
return {"passed": outcome["passed"], "checks": {"installedPatched": outcome["passed"]}}
172+
173+
sleeps = []
174+
kind, payload = pipenv.retry_transport(run_case, self.JOB, case, root, sleep=sleeps.append)
175+
return kind, payload, calls, sleeps
176+
177+
def test_pypi_503_error_is_retried_from_a_fresh_case_and_keeps_evidence(self):
178+
with tempfile.TemporaryDirectory() as temp:
179+
root = Path(temp)
180+
kind, payload, calls, sleeps = self.drive([{"raise": "pipenv install failed (exit 1):\n" + self.PYPI_503}, {"passed": True}], root)
181+
self.assertEqual((kind, payload["passed"], len(calls), sleeps), ("row", True, 2, [10]))
182+
retry = payload["transportRetries"][0]
183+
self.assertIn("too many 503 error responses", retry["error"])
184+
self.assertTrue((root / retry["evidence"] / "install.log").is_file())
185+
saved = json.loads((root / "captures/2023.12.1-direct-hosted/result.json").read_text())
186+
self.assertEqual(saved["transportRetries"], payload["transportRetries"])
187+
188+
def test_failed_check_with_transport_failure_in_a_log_is_retried(self):
189+
with tempfile.TemporaryDirectory() as temp:
190+
kind, payload, calls, _ = self.drive([{"passed": False, "log": self.PYPI_503}, {"passed": True}], Path(temp))
191+
self.assertEqual((kind, payload["passed"], len(calls)), ("row", True, 2))
192+
self.assertEqual(payload["transportRetries"][0]["failedChecks"], ["installedPatched"])
193+
194+
def test_patch_api_5xx_is_a_transport_failure(self):
195+
with tempfile.TemporaryDirectory() as temp:
196+
outcomes = [{"raise": "scan failed: API request failed with status 503: upstream connect error"}, {"passed": True}]
197+
self.assertEqual(len(self.drive(outcomes, Path(temp))[2]), 2)
198+
199+
def test_macos_dns_failure_is_a_transport_failure(self):
200+
with tempfile.TemporaryDirectory() as temp:
201+
outcomes = [{"passed": False, "log": "NewConnectionError: [Errno 8] nodename nor servname provided, or not known"}, {"passed": True}]
202+
self.assertEqual(len(self.drive(outcomes, Path(temp))[2]), 2)
203+
self.assertTrue(pipenv.TRANSPORT_FAILURE.search("<urlopen error [Errno 8] nodename nor servname provided, or not known>"))
204+
205+
def test_functional_failures_are_never_retried(self):
206+
with tempfile.TemporaryDirectory() as temp:
207+
outcomes = [{"passed": False, "log": "ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE"}]
208+
kind, payload, calls, sleeps = self.drive(outcomes, Path(temp))
209+
self.assertEqual((kind, payload["passed"], len(calls), sleeps), ("row", False, 1, []))
210+
self.assertNotIn("transportRetries", payload)
211+
kind, payload, calls, _ = self.drive([{"raise": "pipenv lock failed (exit 1): ResolutionFailure"}], Path(temp))
212+
self.assertEqual((kind, len(calls)), ("error", 1))
213+
214+
def test_a_persistent_transport_failure_stays_red_after_three_attempts(self):
215+
with tempfile.TemporaryDirectory() as temp:
216+
kind, payload, calls, sleeps = self.drive([{"raise": self.PYPI_503}] * 3, Path(temp))
217+
self.assertEqual((kind, len(calls), sleeps), ("error", 3, [10, 20]))
218+
self.assertEqual([r["attempt"] for r in payload["transportRetries"]], [1, 2])
219+
220+
def test_a_passing_case_runs_once(self):
221+
with tempfile.TemporaryDirectory() as temp:
222+
kind, payload, calls, _ = self.drive([{"passed": True, "log": self.PYPI_503}], Path(temp))
223+
self.assertEqual((kind, len(calls)), ("row", 1))
224+
self.assertNotIn("transportRetries", payload)
225+
226+
151227
class PipenvShimTests(unittest.TestCase):
152228
def test_parallel_first_use(self):
153229
# Force every worker to reach symlink creation before any can create

0 commit comments

Comments
 (0)