Skip to content

fix(client): resumeUpload exits on an expired session instead of uploading into it - #8267

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-7870-resume-upload-expired
Aug 13, 2026
Merged

fix(client): resumeUpload exits on an expired session instead of uploading into it#8267
os-zhuang merged 1 commit into
mainfrom
claude/issue-7870-resume-upload-expired

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #7870

What

storage.resumeUpload polls GET /api/v1/storage/upload/chunked/:uploadId/progress before it sends anything, but destructured only totalChunks / uploadedChunks off the response and discarded status.

Since #7667 (PR #7844) a session past its own expires_at is durably stamped expired, and that poll reports it. So a client resuming a dead session learned nothing from the response already in hand, read the file, uploaded a full chunk, and discovered the expiry from the 410 UPLOAD_SESSION_EXPIRED its first chunk PUT came back with. Honest, but it spent an upload to rediscover something it had been told.

The poll's status is now read. expired short-circuits before the file is read or a single byte leaves.

The error shape is the server's, not a new one

The thrown error carries code: 'UPLOAD_SESSION_EXPIRED' and httpStatus: 410 — deliberately the same registered code and status the server answers a chunk PUT against that session with, plus details: { uploadId, expiresAt }.

  • No new error.code was minted. UPLOAD_SESSION_EXPIRED is already registered in ERROR_CODE_LEDGER (packages/spec/src/api/error-code-ledger.zod.ts), so the ADR-0112 closed vocabulary is untouched.
  • No new error class was invented. The SDK has none; its established idiom is the one the private fetch wrapper uses for any non-2xx — new Error(message) with code / httpStatus / details attached. This matches it exactly, so a caller already branching on err.code === 'UPLOAD_SESSION_EXPIRED' keeps matching. The only difference is how early it fires, and that the bytes stay home.
  • No contract change. UploadProgressSchema.status already declares expired; this only consumes a value that was already on the wire. packages/spec is untouched.

Scope

status === 'expired' only, per the grading comment. The guard compares against 'expired' exactly rather than testing truthiness, so every other declared status (in_progress, completing, completed, failed) resumes exactly as before — failed and wider status handling stay out — and a server or fixture that omits status cannot misfire it. That last case is live, not hypothetical: the SDK's own URL-conformance fixture drives this method with a counters-only body.

Landed in the storage.resumeUpload arm only. The wider Promise-of-any sweep over this same file is #8140's, and is not touched here.

Tests

Four cases in packages/client/src/storage-wire-dialect.test.ts, asserting the envelope (code and httpStatus) rather than merely that something threw — a bare Error would satisfy "it throws" while breaking every caller the fix exists to serve.

Reverse verification, direction predicted before running: with the guard removed, the two expiry cases go red and the two non-misfire cases stay green.

× throws the registered code and status ...  → expected null to be an instance of Error
× sends no chunk PUT and no complete ...     → expected "vi.fn()" to be called 1 times, but got 3 times
✓ still resumes normally when the session is live
✓ does not misfire when a server or fixture omits status entirely

That second failure is the defect stated numerically: pre-fix, resume made three requests against a session already reported dead. Restored byte-identically afterwards (git hash-object 7ea8a55c9d0217ea8521ce27af7bfd814fb12294 before and after).

Green with the fix in place: pnpm --filter @objectstack/client test — 23 files / 300 tests passed. pnpm --filter @objectstack/client typecheck — clean.


Generated by Claude Code

…ading into it (#7870)

`storage.resumeUpload` polls progress before sending anything, but destructured
only `totalChunks` / `uploadedChunks` and discarded `status`. Since #7667 a
session past its own `expires_at` is durably stamped `expired` and reported by
that poll, so resume walked into the chunk loop and learned the session was dead
from the 410 `UPLOAD_SESSION_EXPIRED` its first chunk PUT returned -- an honest
failure that spent a whole chunk upload to rediscover what it already held.

`expired` now short-circuits before the file is read, throwing an Error carrying
`code: 'UPLOAD_SESSION_EXPIRED'` and `httpStatus: 410` -- the registered code and
status the server answers this same condition with, so a caller's existing
branch keeps matching. Compared with `=== 'expired'` exactly: every other
declared status resumes as before, and an absent `status` cannot misfire it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pY4Xb2iDecfDtT3CWoiTW
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 13, 2026 1:25am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/client.

11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via packages/client)
  • content/docs/api/client-sdk.mdx (via @objectstack/client)
  • content/docs/api/data-flow.mdx (via @objectstack/client)
  • content/docs/api/environment-routing.mdx (via @objectstack/client)
  • content/docs/api/error-catalog.mdx (via @objectstack/client)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/client)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/client)
  • content/docs/kernel/runtime-services/index.mdx (via packages/client)
  • content/docs/permissions/authentication.mdx (via @objectstack/client)
  • content/docs/plugins/packages.mdx (via @objectstack/client)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/client)

3 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/client)
  • content/docs/releases/v16.mdx (via @objectstack/client)
  • content/docs/releases/v17.mdx (via @objectstack/client)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 13, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 13, 2026 01:41
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit aca68eb Aug 13, 2026
26 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-7870-resume-upload-expired branch August 13, 2026 01:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding: storage.resumeUpload ignores progress.status, so resuming an expired session surfaces as a 410 on the first chunk instead of a clean early exit

2 participants