Skip to content

fix(server): reuse the S3 client and retry a stalled blob GET - #539

Open
rita-aga wants to merge 1 commit into
mainfrom
cursor/s3-blob-get-retry-9061
Open

rita-aga wants to merge 1 commit into
mainfrom
cursor/s3-blob-get-retry-9061

Conversation

@rita-aga

@rita-aga rita-aga commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Why

On 2026-10-23, TemperPaw S3 get calls were slow but still succeeding, and every Temper blob-transport monitor flapped all day. Some File $value reads failed outright.

Root cause

Every BlobStore::s3 built its own reqwest::Client, so each get paid for a new TCP and TLS handshake. That client had only a 10s connect timeout, no connection pool and no retry.

  • Successful S3 get spans were idle for almost their whole duration: busy time was 0–8ms, and span p95 was about 1.9s. Semaphore wait stayed around 5ms p95, so callers weren't queueing.
  • 43 gets were cancelled at about 7.9s while their socket was still idle. They never recorded an outcome, so the File response failed instead of returning the object.

Change

  • All S3 calls now share one process-wide client: 2s connect timeout, 45s idle pool with up to 32 idle connections per host, and TCP and HTTP/2 keepalives.
  • A buffered GET gets two tries inside a 7s budget, 3s each. It retries only on a timeout, a connect failure or a dropped body. HTTP statuses, including 404 and 5xx, are never retried.
  • Puts and streaming reads use the same client and are not retried.
  • The decision is recorded as ADR-0182.

Tests

cargo test -p temper-server --lib blob_store::: 18 passed. Two of them are new:

  • a stalled get is abandoned and the retry returns the object within budget;
  • a get that answers in time succeeds once and is not retried.

Note

The five Katagami read_app 502s between 6:34 and 6:49 PM ET are a separate failure. They come from fuel_exhausted in wasm.invoke.run during git-upload-pack, not from blob transport, and this PR doesn't fix them.

RetriggerView in GreptileConfidence Score: 4/5

Fix the timeout for healthy, longer downloads before merging.

Fix All in Claude CodeFindings

  1. P1 Healthy downloads time out ▶
  2. P2 Connection reuse is untested ▶
Fix with agent prompt
### Issue 1
crates/temper-server/src/blob_store/s3_transport.rs:156
`read_object_once` applies the three-second timeout to the entire download, not just a stalled connection. File `$value` reads use this buffered path. A healthy transfer of 20 MB at 5 MB/s is therefore cancelled on both attempts, failing a read that previously had a five-minute budget. Bound connection or idle waits separately, or preserve a longer budget for downloading the body.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

### Issue 2
crates/temper-server/src/blob_store/s3_transport.rs:65-67
Both new tests use `S3Http::for_test`, which creates a separate client and disables connection pooling. They can stay green if production stores start creating separate clients again, bringing back the repeated handshakes this PR fixes. Add a test that reads through two separately constructed `BlobStore::s3` instances at the same endpoint and checks that they reuse one TCP connection.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Shares one S3 HTTP client across blob stores and adds a bounded retry for buffered GETs.

  • S3 blob operations reuse one shared HTTP client.
  • Buffered S3 reads retry a stalled request within a short budget.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Buffered blob GET] --> B[Shared S3 client]
  B --> C[Attempt: three seconds including body]
  C --> D{Result}
  D -->|Success or 404| E[Return result]
  D -->|Other HTTP status| F[Return error]
  D -->|Retryable transport error| G{Attempt remaining?}
  G -->|Yes, within budget| C
  G -->|No| F
Loading

Reviews (1) · Last reviewed commit: "fix(server): reuse the S3 client and ret..." · Reviewed by Greptile

Every BlobStore::s3 built a new reqwest client, so each get paid a fresh
TCP and TLS handshake. On 2026-10-23 those handshakes were the transport:
successful s3 gets were idle for nearly their whole duration, p95 about
1.5s with a 2.5s tail, while semaphore wait stayed around 5ms. Gets still
idle at about 8s were cancelled before they recorded an outcome, and the
File response failed.

Share one process-wide client (pool idle 45s, connect timeout 2s) and
retry a buffered GET once inside a 7s budget. HTTP statuses are not
retried. Puts and streams share the client without the retry.
retryable: false,
transport: BlobTransportError::message(message),
})?;
let mut request = self.http.client().get(&url).timeout(timeout);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Healthy downloads time out

read_object_once applies the three-second timeout to the entire download, not just a stalled connection. File $value reads use this buffered path. A healthy transfer of 20 MB at 5 MB/s is therefore cancelled on both attempts, failing a read that previously had a five-minute budget. Bound connection or idle waits separately, or preserve a longer budget for downloading the body.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-server/src/blob_store/s3_transport.rs
Line: 156

Comment:
**Healthy downloads time out**

`read_object_once` applies the three-second timeout to the entire download, not just a stalled connection. File `$value` reads use this buffered path. A healthy transfer of 20 MB at 5 MB/s is therefore cancelled on both attempts, failing a read that previously had a five-minute budget. Bound connection or idle waits separately, or preserve a longer budget for downloading the body.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex Fix in Cursor

Comment on lines +65 to +67
let client = reqwest::Client::builder()
.connect_timeout(attempt_timeout)
.pool_max_idle_per_host(0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Connection reuse is untested

Both new tests use S3Http::for_test, which creates a separate client and disables connection pooling. They can stay green if production stores start creating separate clients again, bringing back the repeated handshakes this PR fixes. Add a test that reads through two separately constructed BlobStore::s3 instances at the same endpoint and checks that they reuse one TCP connection.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-server/src/blob_store/s3_transport.rs
Line: 65-67

Comment:
**Connection reuse is untested**

Both new tests use `S3Http::for_test`, which creates a separate client and disables connection pooling. They can stay green if production stores start creating separate clients again, bringing back the repeated handshakes this PR fixes. Add a test that reads through two separately constructed `BlobStore::s3` instances at the same endpoint and checks that they reuse one TCP connection.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

This branch has not been deployed

No deployments
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