fix(upload): a retry after release returns the stored photo instead of refusing it
The idempotency key was only readable as a multipart FIELD, and a field cannot be read until the body is being parsed — which happens after the lock/release pre-flight. So the replay was unreachable in exactly the case it exists for: the photo commits → the response is lost on the way back (the flaky-wifi failure the key was added for) → the host releases the gallery at the end of the night → the phone's retry answers `gallery_released`. The guest is told a photo that is sitting in the gallery was never sent. And the remedy the client offers is destructive: `open_event` clears `export_released_at` AND bumps `export_epoch`, retiring the whole keepsake generation and forcing a multi-GB rebuild on a 2-vCPU box at midnight — to re-send a photo that was never missing. Several guests on one flaky evening make this likely to happen at least once. The key is now also sent as `X-Client-Upload-Id`, which arrives with the request line, so the answer is knowable before anything is decided about locks. The multipart field stays for the concurrent case and as a fallback. Placed ahead of the hourly rate limiter too, which was the same mistake one layer up: a 40-photo burst with two retries apiece exhausted the guest's hour on uploads that had all committed the first time. The body is still drained rather than abandoned — replying before reading it makes the proxy see a broken pipe and turn a clean 200 into a 502. The spec carries its own control: a DIFFERENT photo is asserted to still be refused with `gallery_released` after the release, so the replay cannot be green merely because the gate was open.
This commit is contained in:
@@ -1127,6 +1127,17 @@ async function uploadItem(id: string): Promise<void> {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/v1/upload');
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
// The SAME idempotency key as the multipart field above, in a header.
|
||||
//
|
||||
// The field alone cannot be read until the body is being parsed, which is after the
|
||||
// release/lock pre-flight — so a retry of a photo that was already stored got
|
||||
// `gallery_released` instead of its original row, and the guest was told a photo that
|
||||
// IS in the gallery had not been sent. The only remedy on offer (ask the hosts to
|
||||
// reopen) bumps the export epoch and destroys the released keepsake.
|
||||
//
|
||||
// A header arrives with the request line, so the server can replay before it decides
|
||||
// anything about locks. The field stays for the concurrent case and as the fallback.
|
||||
xhr.setRequestHeader('X-Client-Upload-Id', entry.id);
|
||||
// Wall-clock backstop only — generous enough that a slow-but-alive LTE upload is
|
||||
// never killed by it. See MIN/MAX_UPLOAD_TIMEOUT_MS.
|
||||
xhr.timeout = Math.min(
|
||||
|
||||
Reference in New Issue
Block a user