Skip to content

fix(ci): retry a dependency install only when the transport failed - #58

Open
nilsonfh wants to merge 1 commit into
mainfrom
fix/ci-pip-install-retry
Open

nilsonfh wants to merge 1 commit into
mainfrom
fix/ci-pip-install-retry

Conversation

@nilsonfh

Copy link
Copy Markdown
Contributor

What

bd syntgrid-ahx. CI jobs keep dying in the dependency-install step on a cut package fetch, and a red check that no change caused trains readers to rerun rather than read.

tools/pip_install_retry.py retries an install only when the failure is a transport error, and the five install steps in ci.yml and pages.yml now route through it.

The measurement

Six failures on 2026-09-16, none of them a failing test, every one green on a rerun of the same commit:

Where Jobs that died
PR #43 Documentation build
PR #54, first attempt test + Documentation build
main @ 39df523 Governed project contracts
PR #56, first head test
PR #57, run 35151372093 test + Documentation build

The byte counts identify the cause: identical across the jobs of one run, different between runs — PR #57 both jobs IncompleteRead(214027 bytes read, 3458 more expected), PR #54 both 230547/273, main 9580/278. One upstream response is cut and whichever jobs are installing at that moment die together, so it is the fetch, not any job.

Why pip's own retries do not cover it: every site already runs with pip's defaults and setup-python's cache: pip. Those cover a connection that fails to open; a body cut mid-download surfaces from the install phase as OSError. The unit that has to be retried is the install.

What it deliberately does not do

It does not retry a resolution error, a missing extra, or a build failure — those are this repository's bugs and must fail on the first attempt, in seconds. TRANSIENT_MARKERS is a narrow, stated list of transport failures, and a test asserts no resolver wording can enter it.

Upgrading pip itself is left as a plain pip install --upgrade pip, so the retry cannot mask a broken pip.

Why a tool rather than a shell loop in the YAML

Five call sites would be five copies of the same loop, and inline workflow logic cannot be tested — the same reason tools/ci_main_status.mjs exists. tests/test_pip_install_retry.py drives every branch against a real child process, not a mock, since what the tool classifies is a subprocess's combined output.

Verification

  • 8 tests: the classifier, a cut fetch retried into success, a cut fetch on every attempt still failing with the last exit code, and the branch that matters most — a genuine failure not retried and not slept on.
  • Wiring gated both ways: no workflow install may bypass the tool, and the tool must actually appear in both workflows. Without the second, CI could silently stop calling it while the tool kept passing its own tests.
  • Real pip, not only the fake: python tools/pip_install_retry.py --attempts 2 -- --dry-run --no-deps --quiet wheel exits 0, and an invocation with nothing to install is rejected by argparse.
  • The workflow-parsing gates still pass (test_mypy_ratchet, test_ci_main_status), both YAML files parse, and the docs/hygiene gates pass with tools/README.md updated to thirteen scripts plus its index row.
  • Full suite: 2215 passed, 76 skipped (the operator-verified heavy studies, whose outputs are gitignored).
  • black, isort, flake8 clean.

Not in this PR

The deeper option — baking the dependency set into the self-hosted runner image — is not taken: that image rebuilds only when its Dockerfile changes, so the baked environment would go stale on every pyproject.toml edit. Its own header states the principle it follows ("the runtime a workload needs belongs in the image that runs it") for the OS-level runtime, which is a different question from Python packages that change with the tree.

🤖 Generated with Claude Code

On 2026-09-16 six CI jobs across five pull requests and main died in the
dependency-install step, none of them on a red test:

  ERROR: Could not install packages due to an OSError:
  ('Connection broken: IncompleteRead(214027 bytes read, 3458 more expected)')

Every one went green on a rerun of the same commit. The byte counts say what
it is: identical across the jobs of a single run (#57: both 214027/3458; #54:
both 230547/273), different between runs. One upstream response is cut and
whichever jobs are installing at that moment die together.

pip's own retries do not cover it. Every site already runs with pip's defaults
and setup-python's pip cache; those retries cover a connection that fails to
open, while a body cut mid-download surfaces from the install phase as OSError
and takes the process down. The unit to retry is the install.

tools/pip_install_retry.py retries ONLY the measured transport signatures. A
resolution error, a missing extra or a build failure still fails on the first
attempt, in seconds -- a real break must not hide behind three attempts and a
timeout. It lives in tools/ rather than as five copies of a shell loop because
inline workflow logic cannot be tested, the same reason ci_main_status.mjs is
there.

Wired into all five install steps (four in ci.yml, one in pages.yml).
Upgrading pip itself is left alone, so the retry cannot mask a broken pip.

tests/test_pip_install_retry.py drives the tool against a real child process,
including the branch that matters most -- a genuine failure is NOT retried --
and gates the wiring in both directions, so CI cannot silently stop calling it.

bd ahx

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant