Files
PaperClipAI/server/src
DottaandClaude Opus 5.5 34c1e6469c fix(storage): retry small uploads and resume broken downloads (#20313)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A task's files live in a sandbox. Paperclip saves them to object
storage when a run ends and restores them when the next run starts
> - Object storage has short blips: a single 500, a reset connection, a
download cut off halfway. Each of these used to fail the whole save or
restore once, with no second try
> - This pull request makes a short blip invisible. Longer outages are
handled by the storage-unavailable wait (#20299 and a follow-up)
> - The benefit is that tasks keep moving through ordinary storage
failures without anyone clicking Retry

## Linked Issues or Issue Description

Refs #19831. Storage chaos waves `w-20261109T145628-s9902` (ST1) and
`w-20261109T151711-s9903` (ST2).

- A single-request upload (files up to the multipart threshold, which
includes most workspace archives) sent once. A 500 or connection reset
failed the save.
- A download whose body broke mid-stream failed the restore, even though
S3 can resume from the byte offset.

Related: #20299 (storage outage is shown as an infrastructure wait
instead of a failure).

## What Changed

- `server/src/storage/s3-multipart.ts`: `sendFileRequestWithRetry`
resends a single file request and records throttles and backoff in the
save timing. It uses the same throttle backoff as multipart parts for
429/503/SlowDown and up to the same part attempt count for other
failures. It honours the caller's abort signal.
- `server/src/storage/s3-provider.ts`:
- A file-backed `putObject` under the multipart threshold uses that
retry and reopens the source file for each attempt, so a resend never
reuses a half-read stream. The Content-MD5 is computed once from the
file and still verified by S3 on each attempt.
- `getObject` resumes a body that fails with a transient socket error
(reset, timeout, premature close and similar). It continues with a
ranged GET from the byte where the stream stopped, at most three times.
The resumed request carries `If-Match` with the first response's ETag,
so a changed object fails instead of mixing bytes. Body-failure
diagnostics are still recorded on every response. Destroying the
returned stream closes the S3 body it is reading, even mid-read, so read
timeouts and client disconnects still release the connection.
- Tests:
- `server/src/__tests__/storage-s3-transient-retry.test.ts` (new) runs
against a local HTTP server speaking S3.

## Verification

Red/green (each new test fails against master and passes with this PR):
- `storage-s3-transient-retry.test.ts`:
- A small upload is resent after a 500 and after a connection reset,
re-reading the file. The second PUT carries the full content.
- A download cut off mid-body resumes with `Range: bytes=<offset>-` and
`If-Match`, and returns the complete bytes.
- A download whose object changed before the resume fails with a
precondition error and does not return mixed bytes.
  - A stalled download that its reader destroys closes its connection.

Regression: the storage and workspace-finalization suites pass locally,
and `tsc --noEmit` passes for `server`.

## Risks

- Resending an upload is safe because each attempt writes the full
object with the same Content-MD5. Resuming a download is bounded to
three resumes and refuses a changed object.
- No schema migration and no API change.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:10:57 -06:00
..
…
…
…
…
…