Skip to content

fix(mc): retry transport failures, stop reporting them as "Artifact not found" - #2

Merged
AminDhouib merged 5 commits into
mainfrom
ci/track5/download-retry
Sep 12, 2026
Merged

AminDhouib merged 5 commits into
mainfrom
ci/track5/download-retry

Conversation

@AminDhouib

Copy link
Copy Markdown
Member

The failure this fixes

DevinoSolutions/stealth-chrome-devtools-mcp run 34640838095, job
103404145715 (release-gate / release-evidence). Four artifacts downloaded
cleanly, then the connection to the endpoint was reset and the action announced
a missing artifact — while the job that produced it,
install-smoke (sdist Linux/X64), had passed:

2026-09-11T20:03:22.2097130Z Downloaded release-evidence-build-dist (1.4 KiB, 3 entries) to /home/runner/work/stealth-chrome-devtools-mcp/stealth-chrome-devtools-mcp/release-evidence
2026-09-11T20:03:22.4899911Z Downloaded release-evidence-coverage-Linux-X64 (127.3 KiB, 4 entries) to /home/runner/work/stealth-chrome-devtools-mcp/stealth-chrome-devtools-mcp/release-evidence
2026-09-11T20:03:22.7735534Z Downloaded release-evidence-coverage-Windows-X64 (127.8 KiB, 4 entries) to /home/runner/work/stealth-chrome-devtools-mcp/stealth-chrome-devtools-mcp/release-evidence
2026-09-11T20:03:23.0106496Z Downloaded release-evidence-coverage-macOS-ARM64 (126.6 KiB, 4 entries) to /home/runner/work/stealth-chrome-devtools-mcp/stealth-chrome-devtools-mcp/release-evidence
2026-09-11T20:03:23.0789903Z ##[error]Artifact not found: release-evidence-install-smoke-sdist-Linux-X64 (s3://gh-artifacts/DevinoSolutions/stealth-chrome-devtools-mcp/34640838095/release-evidence-install-smoke-sdist-Linux-X64.tgz)
2026-09-11T20:03:23.0791457Z mc: <ERROR> Unable to prepare URL for copying. Get "https://storage.devino.ca/gh-artifacts/?location=": read tcp 10.1.0.14:44618->172.67.206.3:443: read: connection reset by peer
2026-09-11T20:03:23.0871639Z ##[error]Process completed with exit code 1.

Two separate defects in one line of output. The headline names the wrong cause,
and a single TCP reset was enough to red the check.

Why it matters beyond that one run

51 upload steps across 14 repositories now route through this action, 45 of
them on the ubuntu-devino pool, and several of them feed required checks. One
composite action against one endpoint currently has no damping at all: any
transient reset becomes a red required check, in any migrated repository.

What the code did

Store.run() is the only mc wrapper in the action — upload and download,
cp and ls all go through it — and it invoked subprocess.run exactly once,
with no retry:

def run(self, *args, check=True):
    cmd = [self.mc, "--config-dir", self.cfg, "--no-color", "--disable-pager"] + list(args)
    p = subprocess.run(cmd, env=self.env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    out = p.stdout.decode("utf-8", "replace")
    if check and p.returncode != 0:
        fail("mc %s failed (exit %d):\n%s" % (args[0], p.returncode, out.strip()))
    return p.returncode, out

The download loop then passed check=False, which suppresses that accurate
message, and asserted "Artifact not found" for any non-zero exit — reset,
DNS failure, expired credentials or a real missing object alike. The true cause
was appended after a newline, so on a GitHub annotation it lands on the second
line and the headline is wrong.

What it does now

  • classify_mc_error(rc, output) reads mc's message rather than its exit
    code, which is 1 for everything: transport / auth / not-found /
    unknown. Order is deliberate — a DNS failure says "no such host" and must
    not be read as a missing object, and a 503 must not be read as a permission
    problem.
  • Store.run() retries transport only: 5 attempts with exponential
    backoff, 2 / 4 / 8 / 16 s, ±25% jitter so a fleet-wide blip does not retry in
    lockstep. A real 404 and a rejected credential are never retried — they are
    deterministic answers, and retrying only delays the report by half a minute.
    An unrecognised failure is not retried either.
  • Because Store.run() is the single wrapper, upload, download and listing
    all inherit this
    — that is the whole of the "same fix on the upload path".
    Both verbs are safe to repeat: cp writes a whole object under a key derived
    from the run, and ls is read-only.
  • download_error_message() keeps the familiar Artifact not found for a
    genuine 404 and otherwise leads with mc's own text, printed verbatim, with
    the attempt count and an explicit "not a missing artifact".
  • A retry emits ::warning:: with mc's first line, so a run that recovers
    still shows what happened.

Nothing on a healthy run changes: success on the first attempt returns exactly
as before, with no sleep and no extra output.

The OIDC / STS credential flow is untouched. No credential is printed; the
existing ::add-mask:: calls are unchanged.

Tests

tests/test_mc_retry.py, 30 tests, standard library unittest only, matching
the module's own no-dependency rule. subprocess.run and time.sleep are
mocked, so nothing touches the network, mc, the endpoint or the OIDC flow.
The transport sample is the exact mc line from the log above.

$ python -m unittest discover -s tests -p "test_*.py"
Ran 30 tests in 0.007s

OK

Covered: the observed reset and ten other transport shapes; four missing-object
shapes; five credential shapes; the DNS-versus-404 ordering guard; the backoff
schedule, jitter bounds and index clamping; first-attempt success taking no
retry and no sleep; transport-then-success; exhaustion after exactly
MC_ATTEMPTS calls and MC_ATTEMPTS - 1 sleeps; 404, auth and unknown never
retried; check=False returning instead of exiting while still retrying
transport; the tagged upload command retrying with its arguments unchanged; and
do_download() end to end, asserting that a reset no longer produces an
Artifact not found annotation while a real 404 still does.

Mutation-checked rather than asserted: reverting the download message, removing
the retry, and retrying every error class each make the suite fail (1, 7 and 6
failing tests respectively).

A new unit job in ci.yml runs them on ubuntu-latest. It needs no OIDC, no
mc and no self-hosted runner, so it adds no load to the pool.

Checks

.github/workflows/ci.yml already exercises the real round trip on
ubuntu-latest, windows-latest, macos-latest and ubuntu-devino, plus a
cross-job download, so the happy path is covered against live MinIO by this
PR's own checks.

LF only, 0 CR bytes in all four files (counted on raw bytes).

…und"

A download from storage.devino.ca hit a TCP reset mid-run and the action
reported it as "Artifact not found", failing a required check while the
producing job had passed
(stealth-chrome-devtools-mcp run 34640838095, job 103404145715).

Store.run() was the single mc wrapper and called subprocess.run exactly
once, so any blip against the endpoint failed the step outright; the
download path then passed check=False and asserted "Artifact not found"
for every non-zero exit, whatever the cause.

- classify_mc_error() reads mc's message: transport / auth / not-found /
  unknown. Order matters, so a DNS failure is not read as a missing object.
- Store.run() retries transport failures only: 5 attempts, 2/4/8/16 s with
  +/- 25% jitter. A 404 or a rejected credential is never retried. Both
  verbs are safe to repeat (cp writes a whole object, ls is read-only), so
  upload, download and listing all inherit this.
- download_error_message() keeps "Artifact not found" for a real 404 and
  leads with mc's own text otherwise, printed verbatim.
- 30 unit tests, subprocess mocked, no network. New ci.yml "unit" job.

The OIDC/STS flow is untouched.
mc echoes the object key in its error text, so the bare
\b(429|500|502|503|504)\b alternation would read a genuinely missing
"coverage-503" as a 5xx: five pointless retries, then the wrong label on
the error. A bare code now only counts when something says it is one
("status:", "code:", "responded with", "returned"); the reason phrases
(bad gateway, service unavailable, gateway timeout, too many requests)
already carried the real cases on their own.

Same for 401/403 on the auth side, with "forbidden"/"unauthorized" added
so mc's bare "403 Forbidden" is still classified.

Bare EOF is now anchored to Go's ": EOF" shape for the same reason.

3 new tests (33 total): five 5xx-looking and two 4xx-looking artifact
names that do not exist must classify as not-found, and the observed
reset with its ports and IPs must still classify as transport.
…dout

The em-dash I had put in the new download error would have raised
UnicodeEncodeError on a Windows runner: fail() prints to stdout, and
before Python 3.15 that is the console code page (cp1252), which cannot
encode it. The error path would have replaced a useful message with a
traceback, on exactly the failure this PR exists to report well.

The three pre-existing non-ASCII strings in the file are all summary()
text, which append_file() writes with encoding="utf-8", so they are
unaffected and unchanged.

One test (34 total): every download_error_message() variant must encode
as ASCII.
The roundtrip matrix includes ubuntu-devino, and this workflow had no
concurrency key, so every push to every branch queued a job on the
saturated self-hosted pool that nothing ever cancelled. Three pushes on
this PR left three queued pool jobs competing with each other, two of
them testing stale commits.

group: ci-${{ github.ref }} matches the prevailing style across the org
(31 of the 257 workflow-level concurrency blocks in DevinoSolutions use
exactly that group name). main is exempt from cancellation so a push
there always finishes validating; everywhere else the newest commit wins.
…duled

DevinoSolutions/artifact is public, and the org's only self-hosted runner
group (Default) has allows_public_repositories: false, so every job here
asking for `ubuntu-devino` queues forever instead of failing. On PR #2 the
roundtrip matrix cell sat queued for over four hours across four runs and
never started once.

That restriction should stay: putting a public repo on self-hosted runners
exposes them to fork pull requests. So the workflow moves instead.

- roundtrip matrix drops ubuntu-devino, keeping the three hosted platforms.
- cross-job download moves from ubuntu-devino to ubuntu-latest. What it
  tests is that one job can fetch artifacts uploaded by other jobs on other
  operating systems, which does not depend on where it runs itself; its
  verification loop drops to the three OSes that now upload.

Nothing is lost in coverage: the Linux-on-pool path is exercised
continuously by the 51 consumer upload steps that call this action on
ubuntu-devino from private repositories.

Correction to the record: the two ubuntu-devino runs that passed on
2026-09-02 were NOT from a time when the repo was private. Its PublicEvent
is 2026-09-02T05:45:04Z, the same instant as created_at, and those runs
started at 06:17Z and 06:18Z. The repo has been public since creation, so
it is the runner-group setting that was tightened afterwards.
@AminDhouib
AminDhouib merged commit 6a4cf63 into main Sep 12, 2026
5 checks passed
@AminDhouib
AminDhouib deleted the ci/track5/download-retry branch September 12, 2026 00:37
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