Sending a capture: no Idempotency-Key, so a lost reply turns a retry into a second capture and notification #21

Closed
opened 2026-10-01 10:47:17 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo-chromium at 7998de372, reading the outbox against the core's capture endpoint at postulo 95348908d.

Since postulo/postulo#230 the core honours an Idempotency-Key header on POST /api/v1/captures: a request repeated under the same key gets the first answer back, with no second capture and no second notification. The extension never sends one, while its delivery is at-least-once by design: the popup sends at once, the background retries every few minutes and at start-up, and the captures page retries when opened. A capture that Postulo created but whose answer never reached the browser's store is therefore sent again and created twice.

  • Where:
    • src/lib/api.js:57-65, headers(): only Authorization, Accept and Content-Type; nothing in src/ mentions an idempotency key.
    • src/lib/api.js:171-187, capture(): the POST, aborted after TIMEOUT_MS (20,000 ms, api.js:147).
    • src/lib/outbox.js:53-70, deliver(): the record becomes sent only after the response has been read and store.update has run; until then it stays waiting and is picked up by the next delivery.
    • src/popup.js:158-160: Send saves the record as waiting and delivers from the popup itself.
  • What happens:
    1. A person presses Send for review and clicks back into the page while the request is in flight. The popup closes as soon as it loses focus (as store.js itself notes), ending the delivery after Postulo has created the capture but before the record is marked sent.
    2. Within five minutes the background sends the same record again, and Postulo creates a second capture and announces it again.
      The same happens whenever a capture takes Postulo longer than 20 seconds (a slow instance, or many notifiers): the client aborts, the server finishes, and the retry duplicates it.
  • Why it matters: the person has two captures of one posting to decline and is notified twice, which is exactly what postulo/postulo#230 was fixed to prevent; the server side is done and unused.
  • Fix: send Idempotency-Key with every capture POST, derived from the record: its id (already a crypto.randomUUID(), store.js:26) plus a revision that changes whenever the person edits the record, because the server answers a reused key with a different payload by 422 idempotency-key-reused. In kindOfFailure (api.js:212-224), treat a 409 (idempotency-key-in-use, the first attempt still being answered) as later rather than refused. Related to postulo/postulo#230, which added the header on the server and is closed.

Done when

  • In test/outbox.test.mjs, delivering the same waiting record twice (the first attempt's reply lost) sends the same Idempotency-Key both times, and editing the record between the attempts changes it.
  • In test/api.test.mjs, a 409 answer is classed as later.
Found on 2026-09-29 in a read-only review of postulo-chromium at 7998de372, reading the outbox against the core's capture endpoint at postulo 95348908d. Since postulo/postulo#230 the core honours an `Idempotency-Key` header on `POST /api/v1/captures`: a request repeated under the same key gets the first answer back, with no second capture and no second notification. The extension never sends one, while its delivery is at-least-once by design: the popup sends at once, the background retries every few minutes and at start-up, and the captures page retries when opened. A capture that Postulo created but whose answer never reached the browser's store is therefore sent again and created twice. - **Where:** - `src/lib/api.js:57-65`, `headers()`: only `Authorization`, `Accept` and `Content-Type`; nothing in `src/` mentions an idempotency key. - `src/lib/api.js:171-187`, `capture()`: the POST, aborted after `TIMEOUT_MS` (20,000 ms, `api.js:147`). - `src/lib/outbox.js:53-70`, `deliver()`: the record becomes `sent` only after the response has been read and `store.update` has run; until then it stays `waiting` and is picked up by the next delivery. - `src/popup.js:158-160`: Send saves the record as `waiting` and delivers from the popup itself. - **What happens:** 1. A person presses Send for review and clicks back into the page while the request is in flight. The popup closes as soon as it loses focus (as `store.js` itself notes), ending the delivery after Postulo has created the capture but before the record is marked `sent`. 2. Within five minutes the background sends the same record again, and Postulo creates a second capture and announces it again. The same happens whenever a capture takes Postulo longer than 20 seconds (a slow instance, or many notifiers): the client aborts, the server finishes, and the retry duplicates it. - **Why it matters:** the person has two captures of one posting to decline and is notified twice, which is exactly what postulo/postulo#230 was fixed to prevent; the server side is done and unused. - **Fix:** send `Idempotency-Key` with every capture POST, derived from the record: its `id` (already a `crypto.randomUUID()`, `store.js:26`) plus a revision that changes whenever the person edits the record, because the server answers a reused key with a different payload by 422 `idempotency-key-reused`. In `kindOfFailure` (`api.js:212-224`), treat a 409 (`idempotency-key-in-use`, the first attempt still being answered) as `later` rather than `refused`. Related to postulo/postulo#230, which added the header on the server and is closed. ## Done when - In `test/outbox.test.mjs`, delivering the same waiting record twice (the first attempt's reply lost) sends the same `Idempotency-Key` both times, and editing the record between the attempts changes it. - In `test/api.test.mjs`, a 409 answer is classed as `later`.
tiagoagueda added this to the 0.5.0 milestone 2026-10-01 10:47:17 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo-chromium#21
No description provided.