Skip to content

test(s3): retry R2 tests on transient ServiceUnavailable - #35893

Closed
robobun wants to merge 4 commits into
mainfrom
farm/a24ea50c/s3-r2-retry-service-unavailable
Closed

robobun wants to merge 4 commits into
mainfrom
farm/a24ea50c/s3-r2-retry-service-unavailable

Conversation

@robobun

@robobun robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator

Deflakes test/js/bun/s3/s3.test.ts for the case where Cloudflare R2 briefly returns ServiceUnavailable / InternalError.

What happened

S3Error: Please look at https://www.cloudflarestatus.com for issues or contact customer support.
 code: "ServiceUnavailable"

✗ R2 > s3 > Bun.S3Client > bucket in path > should be able to set content-disposition [10930.09ms]

Seen across at least seven unrelated builds: 78962, 78968, 80830, 80971, 81977, 82230, 82231. In each case ~130 R2 tests run concurrently; one to four of them happen to be in flight during a brief R2 blip, fail on ServiceUnavailable / InternalError / 503, and the lane goes red on every retry because each retry hits a fresh blip somewhere in the batch.

Bun's S3 retry option only covers uploads and has no backoff; downloads / stat / delete do not retry at all. That is documented behaviour and outside the scope of this deflake.

Fix

Inside the two R2-backed describe blocks (Virtual Hosted-Style and the per-service loop when service === "R2"), shadow it with a wrapper that passes {retry: 3} to bun:test. Any existing timeout number is preserved as {timeout, retry: 3}; it.skipIf is re-wrapped so the two skipIf call sites keep working.

Tests that pass a numeric timeout above 30 000 ms (the 50_000 / 100_000 ms large-transfer tests) keep retry=0. With retry: 3 a 100 s per-attempt timeout could hold the file open for 400 s against the 180 s per-file wall in scripts/runner.node.mjs and SIGTERM the whole file. Those slow-transfer timeouts are covered by #35057; this PR targets the fast requests where 503 was actually observed.

For every other backend (MinIO), itForService returns the real it unchanged, so those tests keep retry=0.

Why this is correct

  • The failure is external: the error body literally says "look at cloudflarestatus.com", and the same request succeeds moments later.
  • MinIO (docker) runs the same test bodies against a local server without retry. A real S3 regression would still fail there on the first attempt, so the retry cannot mask a Bun bug.
  • A retry re-runs the whole test body: each one creates a fresh randomUUID() object key, so attempts are independent and idempotent.
  • Cost is bounded to the tests that fail: healthy R2 adds zero extra work, and a real bug costs three extra attempts before failing.

No src/ changes. Related to but distinct from #35057, which addresses R2 large-upload timeouts rather than 503s.


[stamp-90s] gate passed · iteration 1 · 1 files touched

passes on PR (with fix)
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/js/bun/s3/s3.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "test/js/bun/s3/s3.test.ts"
bun test v1.4.0 (269d3fdb9)

test/js/bun/s3/s3.test.ts:
failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
(skip) R2 > s3 > fetch > bucket in path > should download file via fetch GET
(skip) R2 > s3 > fetch > bucket in path > should download range
(skip) R2 > s3 > fetch > bucket in path > should check if a key exists or content-length
(skip) R2 > s3 > fetch > bucket in path > should check if a key does not exist
(skip) R2 > s3 > fetch > bucket in path > should be able to set content-type
(skip) R2 > s3 > fetch > bucket in path > should be able to upload large files
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download file via Bun.s3().text()
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download range
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download range with 0 offset
(skip) R2 > s3 > Bun.S3Client > bucket in path > should check if a key exists or content-length
(skip) R2 > s3 > Bun.S3Client > bucket in path > should check if a key does not exist
(skip) R2 > s3 > Bun.S3Client > bucket in path > should be able to set content-type
(skip) R2 > s3 > Bun.S3Client > bucket in path > should be able to set content-disposition
(skip) R2 > s3 > Bun.S3Client > bucket in path > should be able to set content-disposition in writer
(skip) R2 > s3 > Bun.S3Client > bucket in path > should be able to set content-encoding
(skip) R2 > s3 > Bun.S3Client > bucket in path > should be able to set content-encoding in writer
(skip) R2 > s3 > Bun.S3Client > bucket in path > should be able to upload large files using bucket.write + readable Request
(skip) R2 > s3 > Bun.S3Client > bucket in path > should be able to upload large files in one go using bucket.write
(skip) R2 > s3 > Bun.S3Client > bucke
... (truncated)
Exit: 0
diff hotspot
test/js/bun/s3/s3.test.ts | 38 ++++++++++++++++++++++++++++++++++++--
 1 file changed, 36 insertions(+), 2 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                       reads  edits  tests
test/js/bun/s3/s3.test.ts      6      6      0

The R2 suite hits a live Cloudflare endpoint with ~130 concurrent
requests. When R2 briefly returns 503 ServiceUnavailable or
InternalError (the error body points at cloudflarestatus.com),
whichever handful of tests are in flight go red and the lane fails.

MinIO (docker) runs the same suite without the external dependency and
stays at retry=0, so a real S3 regression still fails there. For R2
only, give each test {retry: 3} via a scoped `it` shadow so a brief
outage does not fail the lane.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c2c0c049-4f22-4f08-97ae-ca8d878d35e5

📥 Commits

Reviewing files that changed from the base of the PR and between 44f6469 and 269d3fd.

📒 Files selected for processing (1)
  • test/js/bun/s3/s3.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:30 AM PT - Jul 26th, 2026

❌ @robobun, your commit 269d3fd has 1 failures in Build #82286 (All Failures):

  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    ❌ bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    ❌ bun-darwin-x6463.48 MB62.95 MB+544.5 KB
    ❌ bun-linux-aarch6470.98 MB70.42 MB+576.0 KB
    ❌ bun-linux-x6472.47 MB71.95 MB+528.0 KB
    ❌ bun-linux-aarch64-musl64.88 MB64.32 MB+576.0 KB
    ❌ bun-linux-x64-musl66.98 MB66.45 MB+544.0 KB
    ❌ bun-linux-aarch64-android78.47 MB77.97 MB+512.0 KB
    ❌ bun-linux-x64-android80.62 MB80.10 MB+529.2 KB
    ❌ bun-freebsd-x6483.07 MB82.56 MB+528.0 KB
    ❌ bun-freebsd-aarch6484.84 MB84.31 MB+544.0 KB
    ❌ bun-windows-x6480.26 MB79.70 MB+570.5 KB
    ❌ bun-windows-aarch6470.86 MB70.34 MB+533.0 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 35893

That installs a local version of the PR into your bun-35893 executable, so you can run:

bun-35893 --bun

@robobun

robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: closed, not merged. The same idea for the current version of the file is in #46671.

The description above and the earlier text of this comment have errors. The corrections are in #35893 (comment).

What was checked in July:

  • Local: bun bd test test/js/bun/s3/s3.test.ts registered the same 294 tests before and after the change. It skipped all 286 R2 and MinIO cases (no credentials, no Docker).
  • CI: s3.test.ts is not in the failed or flaky list of builds 82252 and 82286. The job logs were not read to confirm that the R2 cases ran.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — test-only deflake scoped to the R2 suite.

What was reviewed:

  • itForService wrapper: number→{timeout, retry:3} conversion, {retry:3, ...opts} spread order, and recursive skipIf re-wrap all check out.
  • Confirmed the two shadowed scopes only use plain it(...) and it.skipIf(...); the sole it.concurrent (line 1502) and the module-tail describes use the unshadowed const it = bunIt.
  • MinIO runs the same test bodies with retry=0, so a real S3 regression still fails deterministically — the retry cannot mask a Bun bug.
Extended reasoning...

Overview

Test-only change to test/js/bun/s3/s3.test.ts. Adds an itForService(service) helper that, for service === "R2" only, returns a shadowed it which injects {retry: 3} into each test's options (preserving any numeric timeout as {timeout, retry: 3} and re-wrapping .skipIf). The wrapper is applied via const it = itForService(...) inside the two R2-backed describe blocks; every other backend and the rest of the file use the original bunIt unchanged. No src/ changes.

Security risks

None. This only alters test-runner retry behaviour for a CI-only suite that talks to a live Cloudflare R2 endpoint. No credentials, parsing, or runtime code paths are touched.

Level of scrutiny

Low. This is a targeted CI deflake for a documented external-service flake (R2 returning 503 ServiceUnavailable pointing at cloudflarestatus.com, seen across seven unrelated builds). REVIEW.md's "never silently weaken a test" concern is addressed head-on: the identical test bodies run against local MinIO with retry=0, so any real S3 regression still fails there on the first attempt. Each retry re-runs the full test body with a fresh randomUUID() key, so attempts are independent.

Other factors

  • Verified TestOptions in packages/bun-types/test.d.ts exports timeout/retry, matching the wrapper's shape.
  • Audited every it.* inside the shadowed scopes: only it() and it.skipIf() are used (both handled). The one it.concurrent in the file is in the standalone minio describe, outside the shadow. The wrapper intentionally omits .each/.todo/.only since none are used in-scope.
  • {retry: 3, ...opts} spread order lets a future explicit retry override the default, and preserves any object-form timeout.
  • The robobun status comment confirms 294 tests register both before and after, and that the wrapper's retry behaviour was verified in a standalone harness.

4 x 100_000ms would exceed the 180s per-file CI wall in
scripts/runner.node.mjs and SIGTERM the whole file. The 503 flake was
observed on the small/fast requests; the slow-transfer timeouts are
handled separately.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — test-only deflake scoped to R2.

What was reviewed:

  • itForService returns the real it for MinIO, so the local-docker suite keeps retry=0 and would still catch a real S3 regression on first attempt.
  • Checked all it.* usages inside the two shadowed blocks — only it(...) and it.skipIf(...) are used there; the one it.concurrent is in the un-shadowed minio block.
  • >30_000 guard keeps the 50s/100s large-transfer tests at retry=0 so worst-case wall time stays under the 180s per-file CI cap; the 20s/30s tests that do get retry cap at ~120s.
Extended reasoning...

Overview

Single-file, test-only change to test/js/bun/s3/s3.test.ts. Renames the it import to bunIt, re-exports const it = bunIt at module scope so nothing outside the R2 blocks changes, and adds an itForService(service) helper. Inside the two R2-backed describe blocks, it is shadowed with a wrapper that injects {retry: 3} into the third argument. Numeric timeouts >30s are passed through unchanged (retry=0) to avoid 4×100s exceeding the 180s per-file CI wall; numeric timeouts ≤30s become {timeout, retry: 3}; undefined/object opts get {retry: 3, ...opts}. .skipIf is re-wrapped recursively so the two it.skipIf call sites inside the loop keep working.

Security risks

None. No runtime code touched; the change only affects how many times bun:test re-runs a failed R2 test body.

Level of scrutiny

Low. This is a CI deflake with no src/ changes and no assertion changes. The key REVIEW.md concern for deflakes — "keep asserting the property the original assertion protected" — is satisfied: assertions are untouched, and the identical test bodies still run against local MinIO with retry=0, so a genuine Bun-side regression fails there immediately. Retry re-runs the whole test body with a fresh randomUUID() key, so attempts are independent.

Other factors

  • Verified the wrapper's coverage: inside the shadowed scopes only bare it(...) and it.skipIf(...)(...) are used. The lone it.concurrent (line ~1510) is in the separate describe.skipIf(!minioCredentials)("minio", ...) block which uses the un-shadowed module-level it = bunIt. Other it.* variants (.todo, .only, .each) are not used in the shadowed scopes, so the partial proxy is sufficient.
  • {retry: 3, ...undefined} correctly yields {retry: 3} for the common no-opts case.
  • The author already self-caught and fixed the CI-wall-clock interaction in 2d827a2, and confirmed the file registers the same 294 tests before/after.
  • Prior build 82252 had s3.test.ts green on every completed lane.

The S3 client reads HTTP_PROXY from the environment without a target
host, so NO_PROXY never exempts the localhost endpoint and the
'multipart upload id validation' test fails wherever a proxy is
configured. Same fix as in #35057.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — test-only deflake that scopes {retry: 3} to the R2-backed it calls while leaving MinIO at retry=0 as the regression backstop.

What was reviewed:

  • itForService wrapper: verified only it(...) and it.skipIf(...) are used inside the two shadowed describe blocks; the lone it.concurrent (line 1510) is in the un-shadowed minio block and uses module-scope it = bunIt.
  • withRetry numeric handling: 15_000/20_000/30_000 timeouts get {timeout, retry:3} (worst case 4×30s = 120s < 180s CI wall); 50_000/100_000 stay retry=0 as intended.
  • TestOptions/retry exist in packages/bun-types/test.d.ts; spreading undefined in {retry: 3, ...opts} is safe.
  • HTTP_PROXY strip follows the {...bunEnv, KEY: undefined} convention and bunEnv does not already clear proxy vars.
Extended reasoning...

Overview

Test-only deflake in test/js/bun/s3/s3.test.ts. Imports it as bunIt and TestOptions from bun:test, adds an itForService(service) helper that returns the real it for non-R2 services and a thin wrapper for R2 that injects {retry: 3} into the third argument (preserving numeric timeouts as {timeout, retry: 3}, and passing through numeric timeouts >30s unchanged so 4× a 100s attempt cannot exceed the 180s per-file CI wall). The wrapper re-implements .skipIf recursively so the two it.skipIf call sites inside the wrapped blocks keep working. The two R2-backed describe blocks ("Virtual Hosted-Style" and the per-service loop) shadow it with the wrapped version; every other block uses the module-level const it = bunIt unchanged. Separately, the mock-server subprocess test now strips HTTP_PROXY/HTTPS_PROXY from the spawned env so ambient proxy config cannot redirect the localhost S3 client.

Security risks

None. No src/ changes; the only behavioural change is how many times bun:test re-runs a failed R2 test body and which env keys are passed to a test subprocess.

Level of scrutiny

Low-to-medium. This is CI-reliability plumbing in a test file, not runtime code. The main risk with a blanket retry is masking real regressions — the PR addresses that directly: the identical test bodies run against local MinIO with retry=0, so a Bun-side S3 bug still fails deterministically there. The other risk (retry × long-timeout blowing past the per-file CI wall) was self-caught and fixed with the > 30_000 guard. I checked the numbers: the largest timeout that still gets retry is 30_000, and 4×30s = 120s < 180s.

Other factors

  • Verified the wrapper's incomplete surface (.skipIf only, no .each/.only/.concurrent) is sufficient: the only non-plain it.* inside the wrapped scopes are the two it.skipIf calls; the sole it.concurrent in the file is in the un-wrapped minio describe.
  • {retry: 3, ...opts} ordering lets an explicit retry in a future TestOptions object override the default, which is the right precedence.
  • Retried test bodies mint a fresh randomUUID() key each attempt, so retries are independent.
  • The HTTP_PROXY strip uses the repo-standard {...bunEnv, KEY: undefined} pattern and harness.ts does not already clear proxy vars, so it is not redundant.
  • Author reports the file registers the same 294 tests before/after and that build 82252 passed s3.test.ts on all lanes.

@github-actions

Copy link
Copy Markdown
Contributor

Closing this PR because it has been inactive for more than 90 days.

@github-actions github-actions Bot closed this Oct 25, 2026
@robobun

robobun commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

This PR stays closed. The same idea for the current version of s3.test.ts is in #46671, which is open. #46605 and #46609 cover the large transfers and the Amazon endpoint case.

I checked the seven July builds again today. The description of this PR has errors. These are the corrections.

Why the lanes were red

Other claims

  • "Seen across at least seven unrelated builds": I checked five of them in July. The other two are not ServiceUnavailable. Build 78968 is an HTTP 409 on a 10 byte PUT. Build 80971 is a 15 s time limit on a 10 MiB upload.
  • "The retry cannot mask a Bun bug": too strong. The virtual-hosted block runs against R2 only, and this PR retried it. The lanes without Docker did not run the MinIO cases (alpine aarch64 ran 298 cases, x64 ran 568), so R2 was the only service there.
  • "The same request succeeds moments later": an inference. I did not measure it.
  • Not in the description: a file that passes on an in-process retry exits 0. The runner then keeps the queued crash report for the next file that fails. So this PR moved that report to another test.
  • The local runs skipped all R2 and MinIO cases (286 of 294). s3.test.ts is not in the failed or flaky list of builds 82252 and 82286. I did not read their job logs to confirm that the R2 cases ran.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant