Skip to content

Commit aca68eb

Browse files
os-zhuangclaude
andauthored
fix(client): resumeUpload exits on an expired session instead of uploading into it (#7870) (#8267)
`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. Claude-Session: https://claude.ai/code/session_016pY4Xb2iDecfDtT3CWoiTW Co-authored-by: Claude <noreply@anthropic.com>
1 parent f598aa8 commit aca68eb

3 files changed

Lines changed: 177 additions & 1 deletion

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/client': patch
3+
---
4+
5+
client: `storage.resumeUpload` exits on an expired session instead of uploading a chunk into it
6+
7+
`resumeUpload` polls `GET .../upload/chunked/:uploadId/progress` before it sends
8+
anything, but read only `totalChunks` / `uploadedChunks` off the response and
9+
discarded `status`. Since #7667 a session past its own `expires_at` is durably
10+
stamped `expired` and reported as such by that very poll — so a client resuming
11+
a dead session learned nothing from the response it already had, uploaded a full
12+
chunk, and discovered the expiry from the 410 `UPLOAD_SESSION_EXPIRED` the chunk
13+
`PUT` came back with. Correct, but it spent an upload to rediscover something it
14+
had been told.
15+
16+
The poll's `status` is now read: `expired` short-circuits before the file is
17+
read or a single byte leaves, throwing an `Error` carrying
18+
`code: 'UPLOAD_SESSION_EXPIRED'` and `httpStatus: 410` — deliberately the same
19+
registered code and status the server answers a chunk `PUT` against that session
20+
with, plus `details: { uploadId, expiresAt }`. A caller already branching on
21+
`err.code === 'UPLOAD_SESSION_EXPIRED'` keeps matching; the difference is only
22+
how early it fires, and that the bytes stay home.
23+
24+
The guard compares against `'expired'` exactly, so every other declared status
25+
(`in_progress`, `completing`, `completed`, `failed`) resumes as before, and a
26+
server or fixture that omits `status` is unaffected.

packages/client/src/index.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2943,6 +2943,11 @@ export class ObjectStackClient {
29432943
/**
29442944
* Resume an interrupted chunked upload.
29452945
* Fetches current progress, then uploads remaining chunks and completes.
2946+
*
2947+
* Throws before uploading anything when the progress poll reports the
2948+
* session as `expired` — an `Error` carrying `code`
2949+
* `'UPLOAD_SESSION_EXPIRED'` and `httpStatus` 410, the same pair the server
2950+
* answers a chunk PUT against a dead session with (#7870).
29462951
*/
29472952
resumeUpload: async (uploadId: string, file: Blob | ArrayBuffer, chunkSize: number, resumeToken: string): Promise<CompleteChunkedUploadResponse> => {
29482953
const route = this.getRoute('storage');
@@ -2951,7 +2956,42 @@ export class ObjectStackClient {
29512956
const progressRes = await this.fetch(`${this.baseUrl}${route}/upload/chunked/${uploadId}/progress`);
29522957
const progress = await progressRes.json() as UploadProgress;
29532958

2954-
const { totalChunks, uploadedChunks } = progress.data;
2959+
const { totalChunks, uploadedChunks, status, expiresAt } = progress.data;
2960+
2961+
// [#7870] A session past its own `expires_at` is durably stamped
2962+
// `expired` by the server (#7667), and THIS poll is where it says so —
2963+
// `status` is a declared member of `UploadProgressSchema`, populated on
2964+
// every progress read. Before this check the value was fetched and
2965+
// dropped: resume walked straight into the chunk loop and learned the
2966+
// session was dead from the 410 `UPLOAD_SESSION_EXPIRED` its first
2967+
// chunk PUT came back with. Honest, but it spent a whole chunk upload
2968+
// to rediscover something the response already in hand had told it.
2969+
//
2970+
// The code and status deliberately MIRROR that 410 rather than naming a
2971+
// new condition: `UPLOAD_SESSION_EXPIRED` is the registered code the
2972+
// server answers this exact case with (error-code-ledger.zod.ts), so a
2973+
// caller's existing `err.code === 'UPLOAD_SESSION_EXPIRED'` branch
2974+
// fires identically whether the expiry was caught here or by the
2975+
// server. Same error shape as the `fetch` wrapper builds for a real
2976+
// non-2xx (message + `code`/`httpStatus`/`details`) — this is an
2977+
// earlier detection of one condition, not a second one.
2978+
//
2979+
// Compared with `=== 'expired'`, never truthiness: the other declared
2980+
// statuses (`in_progress`, `completing`, `completed`, `failed`) all
2981+
// proceed exactly as before — `failed` and wider status handling are
2982+
// deliberately out of scope — and an absent `status` from a server or
2983+
// fixture that omits it cannot misfire the short-circuit.
2984+
if (status === 'expired') {
2985+
const expiredError = new Error(
2986+
`Upload session ${uploadId} expired${expiresAt ? ` at ${expiresAt}` : ''}`
2987+
+ '; start a new chunked upload',
2988+
) as Error & { code: string; httpStatus: number; details: Record<string, any> };
2989+
expiredError.code = 'UPLOAD_SESSION_EXPIRED';
2990+
expiredError.httpStatus = 410;
2991+
expiredError.details = { uploadId, expiresAt };
2992+
throw expiredError;
2993+
}
2994+
29552995
const parts: Array<{ chunkIndex: number; eTag: string }> = [];
29562996

29572997
// 2. Upload remaining chunks

packages/client/src/storage-wire-dialect.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,113 @@ describe('the enveloped storage responses match their declared return types (#36
136136
expect(res.data.eTag).toBe('"abc"');
137137
});
138138
});
139+
140+
/**
141+
* `resumeUpload` short-circuits on the expiry the progress poll already told it
142+
* about (#7870).
143+
*
144+
* Since #7667 a chunked session past its own `expires_at` is durably stamped
145+
* `expired`, `GET .../progress` reports that status, and a chunk PUT against it
146+
* answers 410 `UPLOAD_SESSION_EXPIRED`. `resumeUpload` polls progress FIRST but
147+
* read only the chunk counters off it, so it uploaded a full chunk into a
148+
* session the poll had already declared dead and learned that from the 410.
149+
*
150+
* What these pin is the pair a caller actually branches on — `code` AND
151+
* `httpStatus` — not merely that something threw: the point of the fix is that
152+
* the early exit is INDISTINGUISHABLE from the server's own refusal, so a
153+
* `catch` written against the 410 keeps matching. Asserting only "it throws"
154+
* would stay green if the short-circuit raised a bare `Error`, which is the one
155+
* outcome that would break every such caller.
156+
*/
157+
describe('storage.resumeUpload exits on an expired session before uploading (#7870)', () => {
158+
/** The progress body the server sends for a session past its deadline. */
159+
const expiredProgress = {
160+
success: true,
161+
data: {
162+
uploadId: 'up1',
163+
fileId: 'f1',
164+
filename: 'big.bin',
165+
totalSize: 16,
166+
uploadedSize: 8,
167+
totalChunks: 2,
168+
uploadedChunks: 1,
169+
percentComplete: 50,
170+
status: 'expired',
171+
startedAt: '2026-01-01T00:00:00.000Z',
172+
expiresAt: '2026-01-01T01:00:00.000Z',
173+
},
174+
};
175+
176+
it('throws the registered code and status the server answers a dead session with', async () => {
177+
const { client } = clientReturning(expiredProgress);
178+
179+
// `.rejects.toThrow()` alone cannot see the difference between this and a
180+
// bare Error, so the envelope is asserted off the caught object.
181+
const err = await client.storage
182+
.resumeUpload('up1', new ArrayBuffer(16), 8, 'rtok')
183+
.then(() => null, (e: any) => e);
184+
185+
expect(err).toBeInstanceOf(Error);
186+
expect(err.code).toBe('UPLOAD_SESSION_EXPIRED');
187+
expect(err.httpStatus).toBe(410);
188+
// The expiry instant is what tells a caller/operator WHICH deadline passed,
189+
// so it survives into both the human message and `details`.
190+
expect(err.message).toContain('2026-01-01T01:00:00.000Z');
191+
expect(err.details).toMatchObject({ uploadId: 'up1', expiresAt: '2026-01-01T01:00:00.000Z' });
192+
});
193+
194+
it('sends no chunk PUT and no complete — the poll is the only request made', async () => {
195+
// The whole point of the card: the bytes never leave. If this regresses,
196+
// the throw above would still pass while the client had already spent a
197+
// chunk upload against a dead session.
198+
const { client, fetchMock } = clientReturning(expiredProgress);
199+
200+
await client.storage.resumeUpload('up1', new ArrayBuffer(16), 8, 'rtok').catch(() => {});
201+
202+
expect(fetchMock).toHaveBeenCalledTimes(1);
203+
expect(fetchMock.mock.calls[0][0]).toContain('/upload/chunked/up1/progress');
204+
});
205+
206+
it('still resumes normally when the session is live', async () => {
207+
// The reverse direction: `=== 'expired'` must not swallow the happy path.
208+
// One mock, routed by URL — resume makes three hops (progress, chunk PUT,
209+
// complete) and they answer different bodies.
210+
const fetchMock = vi.fn(async (url: string) => {
211+
const body = url.includes('/progress')
212+
? { success: true, data: { ...expiredProgress.data, status: 'in_progress' } }
213+
: url.includes('/complete')
214+
? { success: true, data: { fileId: 'f1', size: 16 } }
215+
: { success: true, data: { chunkIndex: 1, eTag: '"e2"', bytesReceived: 8 } };
216+
return { ok: true, status: 200, statusText: 'OK', json: async () => body, headers: new Headers() };
217+
});
218+
const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock as any });
219+
220+
const res = await client.storage.resumeUpload('up1', new ArrayBuffer(16), 8, 'rtok');
221+
222+
expect(res.success).toBe(true);
223+
expect(fetchMock.mock.calls.map((c) => String(c[0]))).toEqual([
224+
expect.stringContaining('/upload/chunked/up1/progress'),
225+
expect.stringContaining('/upload/chunked/up1/chunk/1'),
226+
expect.stringContaining('/upload/chunked/up1/complete'),
227+
]);
228+
});
229+
230+
it('does not misfire when a server or fixture omits status entirely', async () => {
231+
// `status` is declared required, but the client is published separately
232+
// from the server it meets and the SDK's own URL-conformance fixture drives
233+
// this method with a counters-only body. An absent status must resume, not
234+
// abort — which is why the guard compares against 'expired' rather than
235+
// testing truthiness or absence.
236+
const fetchMock = vi.fn(async (url: string) => {
237+
const body = url.includes('/progress')
238+
? { success: true, data: { totalChunks: 1, uploadedChunks: 0 } }
239+
: { success: true, data: { chunkIndex: 0, eTag: '"e1"', bytesReceived: 8 } };
240+
return { ok: true, status: 200, statusText: 'OK', json: async () => body, headers: new Headers() };
241+
});
242+
const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock as any });
243+
244+
await expect(
245+
client.storage.resumeUpload('up1', new ArrayBuffer(8), 8, 'rtok'),
246+
).resolves.toBeDefined();
247+
});
248+
});

0 commit comments

Comments
 (0)