Skip to content

fix(ocale): stop losing place uploads to keep-alive connection reuse - #531

Merged
christopher-buss merged 5 commits into
mainfrom
relaxed-benz-0b0dce
Jul 29, 2026
Merged

fix(ocale): stop losing place uploads to keep-alive connection reuse#531
christopher-buss merged 5 commits into
mainfrom
relaxed-benz-0b0dce

Conversation

@christopher-buss

@christopher-buss christopher-buss commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Summary

Place publishes intermittently failed with either an ApiError carrying a
gateway summary (HTTP 400 from gateway ("400 Bad request")) or a
NetworkError (ECONNRESET / fetch failed), leaving a deploy with some
places published and others not. The cause is HTTP keep-alive connection reuse:
Roblox's edge gateway discards idle pooled connections faster than a pooling
fetch expects, and a request written into a discarded connection never
reaches Open Cloud.

The transport now sends connection: close on every request isUploadRequest
classifies, the same predicate that already drops the default timeout, for the
same reason: uploads hold a connection far longer than a JSON call, so they are
the shape that loses this race. Place publishes are the observed victim, but
icon, thumbnail, and binary-input uploads share the exposure.

publish and save additionally gain UPLOAD_METHOD_DEFAULTS, which retries
failures that provably never reached Open Cloud: transient transport codes plus
a synthetic GATEWAY_REJECTED for gateway-served responses. shouldRetry
classifies a gateway-origin ApiError by that code rather than its status,
since the status came from the gateway and says nothing about the request. They
still do not retry 5xx, where the duplicate-write risk actually lives.

ADR-010 gains a dated amendment recording the diagnosis and the trade.

Manual verification

A probe publishing to three places in one universe on the rate-limit queue's own
2s cadence, against the live API:

Client Keep-alive Result
Bun fetch on request in the +5s slot stalls 19.0s, then dies
Node/undici fetch on same slots stall 19.0s, then die
curl, one process per request n/a (fresh connection) 4/4 succeed in ~1s
This branch, Node and Bun off for uploads 6/6 succeed in 0.8-1.2s

Ruled out by the same probe: it is not concurrency (a strictly sequential run
reproduces it), not content processing (it reproduces on a payload the server
rejects in 0.9s), and not payload size or runtime.

Retry safety was measured, not assumed. A killed publish creates no version:
after one, the place's next distinct upload took the version number the killed
one would have used, not the one after it. And Roblox dedupes identical place
content, returning the existing version number rather than minting a new one, so
a retry that races a publish which did land cannot duplicate it.

Real 7 MB publishes still succeed through the new path.

References

Closes #530

🤖 Generated with Claude Code

christopher-buss and others added 4 commits July 29, 2026 17:13
Roblox's edge gateway discards idle keep-alive connections faster than a
pooling fetch expects. A publish written into a discarded connection never
reaches Open Cloud: it surfaces as a gateway error page or a socket reset and
creates no version. Reproduced on both Node and Bun; disabling reuse for the
upload removes it.

Extracts the body validation so the builder stays within the function-length
limit.

Refs #530

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A transport kill or an edge-gateway response cannot have created a place
version, so neither carries the duplicate-write risk that keeps create methods
off the retry path. Classify a gateway-origin ApiError by a synthetic
GATEWAY_REJECTED transport code rather than its status, since that status
belongs to the gateway and says nothing about the request, and give publish and
save their own upload retry policy that recovers both.

Refs #530

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Amends ADR-010 with the keep-alive reuse diagnosis and the two-part decision,
and updates the package retry table with the "never reached Open Cloud" axis.

Refs #530

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The keep-alive reuse race is a property of upload-shaped requests, not of place
publishing, and the transport already classifies that population with
isUploadRequest to drop the default timeout. Move the directive there so icon,
thumbnail, and binary-input uploads get the same protection instead of place
publish carrying a lone special case, and set it after request headers so a
caller cannot silently re-enable pooling.

Also derives UPLOAD_METHOD_DEFAULTS from CREATE_METHOD_DEFAULTS rather than
restating its status list, corrects the retryableTransportCodes doc now that
the list spans a synthetic non-errno code, and tightens two tests that could
not fail for the reason they named.

Refs #530

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 29, 2026 17:04
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
bedrock-next Ready Ready Preview, Comment Jul 29, 2026 5:17pm

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Place publish and save uploads now disable HTTP connection reuse and retry transient failures that are classified as not reaching Open Cloud. Retry policy, request validation, client wiring, tests, and documentation were updated accordingly.

Changes

Place upload reliability

Layer / File(s) Summary
Publish body validation
packages/open-cloud/src/domains/universes/places/builders.ts
Publish request construction now uses a helper for empty-body and format-signature validation.
Upload connection handling
packages/open-cloud/src/internal/http/fetch-client.ts, packages/open-cloud/src/internal/http/fetch-client.spec.ts
Upload requests set connection: close; header precedence and non-upload behavior are tested.
Gateway and transport retry policy
packages/open-cloud/src/internal/http/retry.ts, packages/open-cloud/src/internal/http/retry.spec.ts
Gateway-rejected errors receive a synthetic retry code, and upload defaults retry transient transport failures without enabling general 5xx retries.
Places wiring and validation
packages/open-cloud/src/resources/places/client.ts, packages/open-cloud/tests/integration/resources/places/client.spec.ts, docs/adr/..., packages/open-cloud/CLAUDE.md, .changeset/...
Place publishing uses upload defaults, integration tests cover gateway and socket-reset retries, and documentation records the behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PlacesClient
  participant FetchClient
  participant RobloxGateway
  participant OpenCloud
  PlacesClient->>FetchClient: Send publish/save upload
  FetchClient->>RobloxGateway: Request with connection: close
  RobloxGateway->>OpenCloud: Forward accepted request
  OpenCloud-->>PlacesClient: Publish/save response
  RobloxGateway-->>PlacesClient: Gateway rejection when request is not forwarded
  PlacesClient->>FetchClient: Retry gateway or transport failure
Loading

Possibly related PRs

Suggested labels: documentation, pkg:testing

Suggested reviewers: copilot

Poem

I’m a rabbit with uploads snug,
No stale connections left to tug.
Gateway thumps? We try once more,
While five-oh-oh stays at the door.
Hop, publish, save—success in sight! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #530 by disabling upload connection reuse and retrying only transport/gateway failures that never reached Open Cloud.
Out of Scope Changes check ✅ Passed The changes stay focused on upload retry/connection behavior and related docs/tests, with no clear unrelated code added.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: preventing place upload failures caused by keep-alive connection reuse.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@claude claude Bot added pkg:ocale Scope: @bedrock-rbx/ocale package bug Something isn't working labels Jul 29, 2026
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Claude finished @christopher-buss's task in 5m 16s —— View job


Code Review

Overall: looks correct and well-tested. One recurring policy violation (em-dashes) across four locations; everything else clears the rubric.


Architecture (FCIS)

The layering is correct throughout.

  • connection: close is applied in the transport (applyRequestHeaders in fetch-client.ts) — the right place. The transport-owned directive is set after caller-supplied headers, so a request cannot silently re-enable keep-alive for an upload. A dedicated test pins this.
  • UPLOAD_METHOD_DEFAULTS and GATEWAY_REJECTED live in internal/http/retry.ts. shouldRetry's new gateway branch (error.gatewaySummary !== undefined → check transport codes, never consult status) is pure logic with no I/O — correct Core placement.
  • makePublishSpec wires methodDefaults: UPLOAD_METHOD_DEFAULTS / methodKind: "create" in the resource client — correct Shell placement.

Testing

Test coverage is thorough and idiomatic. All new tests use it("should …"), expect.assertions(N), assert() for Result narrowing, and it.for where applicable.

The integration tests cover:

  • gateway-rejected publish/save retries (happy-path retry)
  • ECONNRESET publish retry
  • 5xx publish/save non-retry (the dup-write guard)

The shouldRetry unit tests add the three new symmetry cases for gateway errors (retried when code allowed, not retried when excluded, not retried just because gateway code is in the list for a non-gateway error). The method-defaults pinning tests in retry.spec.ts are literal and will catch any accidental mutation to the transport code lists.

One observation (not blocking): there is no test confirming that IDEMPOTENT_METHOD_DEFAULTS does not include GATEWAY_REJECTED. The policy says "Extending gateway-rejection retry to idempotent methods is deliberately out of scope," but no test guards the gap. The literal pin of IDEMPOTENT_METHOD_DEFAULTS.retryableTransportCodes at retry.spec.ts:303–316 implicitly covers it (the list doesn't contain GATEWAY_REJECTED), so mutation safety is intact; a dedicated prose case would make the intent explicit.


Types

shouldRetry's return type error is ApiError | NetworkError | RateLimitError is correct. GATEWAY_REJECTED and UPLOAD_METHOD_DEFAULTS are not exported from the package barrel (src/index.ts only re-exports TRANSIENT_TRANSPORT_CODES), so no @example obligation. No as casts, no null, no ambient type violations.


Commit / PR Format

PR title and all four commit subjects are valid scope-enum, lowercase, consumer-facing. The docs(ocale): … commit correctly uses docs as the type and ocale as the scope.


Em-Dashes (policy violation — 4 locations)

CLAUDE.md: "No em-dashes () in commit messages, PR bodies, code comments, or docstrings."

All four locations are in content introduced by this PR:

1. fetch-client.ts:24–25 — inline comment:

// window per upload — real for a multi-megabyte body, and paid again on each
// retry — but cheaper than a lost write.

Fix this →

2. retry.ts:98–99 — JSDoc for UPLOAD_METHOD_DEFAULTS:

 * {@link CREATE_METHOD_DEFAULTS}'s 5xx guard — a 5xx comes from Open Cloud and
 * may describe a write that partly landed — but retries failures that never

Fix this →

3. .changeset/hungry-donkeys-shake.md:8 — changeset body:

reaches Open Cloud — surfacing as a gateway error page or a socket reset, having

Fix this →

4. docs/adr/010-sdk-managed-rate-limiting-and-retry.md — new amendment section (lines 465, 479, 490, 507):
Examples: retried nothing but 429 — and they are the calls most, does not — no pooled connection to reuse, classifies — the same predicate that already drops, identical place content — re-uploading unchanged bytes.
Fix this →

Note: the prior amendments in this file also contain em-dashes, but those are pre-existing and out of scope for this PR.


Summary

The fix is correct, well-evidenced (probe data in the ADR, integration tests exercising the retry paths), and the retry safety reasoning (killed publish creates no version; Roblox dedupes identical content) is sound. The only required change is replacing the em-dashes in four locations.

@coderabbitai coderabbitai Bot added documentation Improvements or additions to documentation pkg:testing Scope: @bedrock-rbx/testing package labels Jul 29, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Fixes intermittent Open Cloud place upload failures caused by keep-alive connection reuse, by forcing upload requests to close connections and by retrying only failures that provably never reached Open Cloud.

Changes:

  • Add connection: close for upload-shaped requests via isUploadRequest, preventing reuse of stale pooled connections.
  • Introduce UPLOAD_METHOD_DEFAULTS and GATEWAY_REJECTED to retry gateway-served ApiErrors and transient transport failures for place uploads without enabling 5xx retries.
  • Add integration/unit tests plus documentation updates (CLAUDE.md + ADR-010 amendment) and a changeset.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/open-cloud/tests/integration/resources/places/client.spec.ts Adds integration coverage for retrying gateway rejection and ECONNRESET on publish/save.
packages/open-cloud/src/resources/places/client.ts Switches publish/save to UPLOAD_METHOD_DEFAULTS and documents the refined retry behavior.
packages/open-cloud/src/internal/http/retry.ts Adds GATEWAY_REJECTED and UPLOAD_METHOD_DEFAULTS, and updates shouldRetry to treat gateway responses as transport-class retry.
packages/open-cloud/src/internal/http/retry.spec.ts Adds unit tests for gateway retry classification and upload defaults.
packages/open-cloud/src/internal/http/fetch-client.ts Applies connection: close for upload requests and refactors request header merging.
packages/open-cloud/src/internal/http/fetch-client.spec.ts Tests the new upload connection behavior and header precedence.
packages/open-cloud/src/domains/universes/places/builders.ts Refactors publish body validation into a helper for clarity while preserving behavior.
packages/open-cloud/CLAUDE.md Documents the new "never reached Open Cloud" retry axis and the upload connection behavior.
docs/adr/010-sdk-managed-rate-limiting-and-retry.md Adds an amendment describing the diagnosis and the new retry/connection tradeoff for uploads.
.changeset/hungry-donkeys-shake.md Adds a patch changeset describing the fix for ocale consumers.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/open-cloud/src/internal/http/fetch-client.ts Outdated
Comment thread packages/open-cloud/src/internal/http/retry.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/open-cloud/src/internal/http/fetch-client.ts`:
- Around line 12-26: Remove the internal implementation comments requested at
all affected sites: in packages/open-cloud/src/internal/http/fetch-client.ts
lines 12-26 remove the connection-reuse rationale, and lines 257-265 remove the
private helper JSDoc; in
packages/open-cloud/src/domains/universes/places/builders.ts lines 55-64 remove
the private helper JSDoc; in packages/open-cloud/src/internal/http/retry.spec.ts
lines 151-152 and 295-296 remove the explanatory test comments while leaving the
self-evident test setup and behavior unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6628fdc4-f6a1-4e37-84ef-e2854e025f93

📥 Commits

Reviewing files that changed from the base of the PR and between 9d6f48b and 9eee349.

📒 Files selected for processing (10)
  • .changeset/hungry-donkeys-shake.md
  • docs/adr/010-sdk-managed-rate-limiting-and-retry.md
  • packages/open-cloud/CLAUDE.md
  • packages/open-cloud/src/domains/universes/places/builders.ts
  • packages/open-cloud/src/internal/http/fetch-client.spec.ts
  • packages/open-cloud/src/internal/http/fetch-client.ts
  • packages/open-cloud/src/internal/http/retry.spec.ts
  • packages/open-cloud/src/internal/http/retry.ts
  • packages/open-cloud/src/resources/places/client.ts
  • packages/open-cloud/tests/integration/resources/places/client.spec.ts

Comment thread packages/open-cloud/src/internal/http/fetch-client.ts
Repo review standard forbids em-dashes in code comments, docstrings, commit
messages, and PR bodies.

Refs #530

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@christopher-buss
christopher-buss merged commit 8338686 into main Jul 29, 2026
11 of 12 checks passed
@christopher-buss
christopher-buss deleted the relaxed-benz-0b0dce branch July 29, 2026 17:25
christopher-buss added a commit that referenced this pull request Jul 31, 2026
## Summary

A consumer's deploy lost all three of its places at once, two of them in
the same millisecond. The codes were new: `ERR_HTTP2_STREAM_ERROR` and
`UND_ERR_INFO`, where this failure has always surfaced as a socket error
before.

Node 26 bundles undici 8, which [enables HTTP/2 by
default](nodejs/undici#4828) (taken into Node in
[nodejs/node#62384](nodejs/node#62384)). That
breaks the mitigation from #531 in two ways. `Connection` is a
connection-specific header, forbidden in HTTP/2, so an h2 transport
drops the `connection: close` uploads set — the directive was doing
nothing. And h2 multiplexes, so every upload shares one session where
HTTP/1.1 gave each its own: one session death now fails every place in
flight, instead of one.

Measured against a local server offering ALPN `["h2", "http/1.1"]`:

| Client | ALPN | TCP connections for 2 uploads | `connection` on the
wire |
| --- | --- | --- | --- |
| Node 26.5.1 `fetch` | h2 | 1 | absent (stripped) |
| Node 24.18 `fetch` | http/1.1 | 2 | `close` |
| Bun 1.3.14 `fetch` | http/1.1 | 2 | `close` |

Bun implements `node:http2`, but its `fetch` does not offer h2 in ALPN,
so Bun is unaffected. The exposure is Node 26 and later.

The fix has two parts:

1. **Uploads pin HTTP/1.1.** There is no standard `fetch` option for
this, and the documented route — `setGlobalDispatcher(new Agent({
allowH2: false }))` — means depending on undici, which ADR-008 rules
out, and mutating a process-wide default, which a library should not do.
Instead the transport reconstructs the class of the runtime's own global
dispatcher with `allowH2: false`. No dependency, and it re-arms the
directive #531 relies on. The symbol is undici's internal contract and
moved from `.1` to `.2` in undici 8, so an absent symbol, a
non-constructible value, or a throwing constructor each fall back to the
runtime's default transport rather than failing the deploy.

2. **The h2 codes join `TRANSIENT_TRANSPORT_CODES`.**
`ERR_HTTP2_STREAM_ERROR`, `ERR_HTTP2_SESSION_ERROR`, and `UND_ERR_INFO`
are the h2 spellings of socket deaths already in that set. This is the
safety net for any runtime where part 1's probe finds nothing.

Part 2 corrects a claim #531 made. The transient set was described as
failures that never reached Open Cloud. That is not true of the h2 codes
— `UND_ERR_INFO` covers both a `GOAWAY` declaring a stream was never
started and a stream that was fully sent — nor of `UND_ERR_SOCKET`,
which can fire once a response is already streaming. For idempotent
methods this changes nothing. For uploads, retry safety rests where #531
actually measured it: Roblox dedupes identical place content, so a retry
that races a publish which did land returns that same version. The doc
comments now say so.

## Verification

A local ALPN server driving the real transport, asserting the symptom
rather than the header: two uploads must reach it over two connections
with `connection: close` on the wire.

```
before  node 26.5.1  FAIL  alpn=h2         tcpConnections=1  connectionHeader=[null,null]
after   node 26.5.1  PASS  alpn=http/1.1   tcpConnections=2  connectionHeader=["close","close"]
after   node 24.18   PASS  alpn=http/1.1   tcpConnections=2  connectionHeader=["close","close"]
after   bun 1.3.14   PASS  alpn=http/1.1                     connectionHeader=["close"]
```

The CI error was also reproduced end to end: a server that sends
`RST_STREAM` mid-upload surfaces `ERR_HTTP2_STREAM_ERROR` through
`fetch`, byte-identical to the deploy log.

Full suite green, 100% mutation score on every touched file.

## Notes for reviewers

- **`http1-dispatcher.spec.ts` has one test that makes a real network
call** (`http://127.0.0.1:1/`, refused in ~20ms). It is deliberate.
Every other test injects a fake scope, so nothing else would notice the
day undici's symbol stops being reachable — which is the day this outage
silently returns with a green suite.
- **A consumer who calls `setGlobalDispatcher(new MockAgent())` in their
own tests would see uploads bypass their interceptors**, because the
fresh agent carries none of them. Uploads only; every other request
still uses the global. I did not guard it: there is no reliable signal
distinguishing a mock from a real agent, and
`OpenCloudClientOptions.httpClient` is the documented test seam.
- **`TRANSIENT_TRANSPORT_CODES` is publicly exported and its meaning
widened** from "proved unprocessed" to "transport-level failure", under
a `patch`. No signature changes.

## Follow-ups, deliberately not in this PR

- **No consumer-reachable override.** If undici's symbol moves again, a
consumer on that runtime loses the mitigation and has no supported
workaround short of an SDK release. A `dispatcher` or `fetchFunc` option
on `OpenCloudClientOptions` would fix that, but it is a public API
change needing its own tests and changeset.
- **A gateway `RST_STREAM(CANCEL)` hangs `fetch` forever.** undici emits
no error event and dequeues the request without rejecting it, and
uploads carry no timeout by design, so the hang is unbounded and retries
cannot reach it — nothing ever fails. Needs its own decision plus an
upstream fix; recorded in the ADR amendment.

## References

Follows #531, which fixed the HTTP/1.1 half of this.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Overall Summary

The PR aligns with Bedrock’s architecture: HTTP dispatcher discovery is
isolated to an adapter, while fetch-client orchestration remains
separate. Transport failures are handled with typed error codes, and the
retry-safety documentation is appropriately narrowed.

Tests cover dispatcher construction, fallback behavior, upload
integration, and retry decisions, including malformed and unavailable
runtime dispatcher shapes.

No blocking architecture, security, or implementation issues were
identified.

## Recommendations

- Confirm CI enforces 100% coverage for the new dispatcher branches.
- Verify all new tests use the required `it("should ...")` naming
convention.
- Retain Node 24, Node 26, and Bun verification because dispatcher
internals are runtime-dependent.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation pkg:ocale Scope: @bedrock-rbx/ocale package pkg:testing Scope: @bedrock-rbx/testing package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: place publish fails with gateway 400 or ECONNRESET from keep-alive connection reuse

2 participants