In this article

An AI-generated retry loop can report success while creating the same task twice. Keep one operation key across attempts, make the receiver recognize it, and test a response lost after the write. This worked JavaScript example checks stored tasks as well as returned values. Repeated POST requests are not inherently idempotent. MDN: POST request method

Define what a missing response actually tells you

Suppose an assistant adds retries to a task-creation request. The first attempt reaches the receiver and creates a task, but its reply never reaches the client. A second attempt succeeds and returns a task identifier. The interface looks recovered; storage now contains two tasks. This is an original illustrative exercise, not a report of a production incident. Its purpose is to expose a behavior that a success-only test would miss.

Write the contract before requesting another patch: retries of one logical creation must produce one stored task; a separate creation with identical text must still produce a separate task. Reusing an operation key with different text must fail. A lost response leaves the outcome uncertain, so exhausting retries may reject even when a task already exists. MDN defines idempotency in terms of the intended server effect of repeated identical requests. MDN: idempotency and intended effects

    Give the receiver an explicit replay contract

    The small receiver below records both tasks and receipts. A receipt associates an operation key with the original title and result. An identical replay returns that result; conflicting contents are rejected before another task is added. The task identifier counts stored creations, making duplicate effects observable. The key identifies the action rather than the text, because users may legitimately create two tasks with the same title.

    All receiver work is synchronous and stays inside one process. That makes the example easy to execute but leaves out database transactions, crashes, multiple workers, authentication, and key expiry. The sequential task IDs are teaching data, and keySequence later supplies deterministic test keys. A real service needs an appropriate unique operation identifier and a documented replay policy; merely sending a header does not implement that policy.

    Illustrative receiver with an in-memory receipt ledger
    Codejavascript
    function createStore() {
      const tasks = [];
      const receipts = new Map();
      return {
        tasks,
        create({ key, title }) {
          if (typeof key !== 'string' || !key ||
              typeof title !== 'string' || !title.trim()) {
            throw new TypeError('key and title must be nonempty strings');
          }
          const saved = receipts.get(key);
          if (saved) {
            if (saved.title !== title) throw new Error('key reused with different title');
            return saved.result;
          }
          const result = Object.freeze({ id: tasks.length + 1, title });
          tasks.push(result);
          receipts.set(key, { title, result });
          return result;
        },
      };
    }
    
    class ResponseLost extends Error {}
    

      Find where the generated loop changes the operation

      This plausible generated client creates a new key on every attempt. It correctly limits attempts and only retries the simulated transport error. However, each new key tells the receiver that this is a different creation. A receiver with perfectly working replay detection therefore cannot recognize the second attempt as a replay. Review the lifetime of the command, not just the catch block or the presence of a key field.

      A useful assistant request is: Trace the operation key through both attempts when the first write succeeds but its response is lost. Identify the number of stored tasks and the returned identifier. Propose the smallest repair that preserves two genuinely distinct user actions. Require an answer about stored state. Identical POST payloads may have additional effects, including repeated creation; the HTTP method alone does not promise deduplication. MDN: POST request method

      Broken draft: a new operation key inside each attempt
      Codejavascript
      async function createTaskBroken(title, send, makeKey) {
        for (let attempt = 0; attempt < 2; attempt++) {
          const command = Object.freeze({ key: makeKey(), title });
          try {
            return await send(command);
          } catch (error) {
            if (!(error instanceof ResponseLost) || attempt === 1) throw error;
          }
        }
      }
      

        Lose the reply after the write, without timing guesses

        The injected transport calls the receiver before discarding the first reply. Its second call returns normally. That ordering forces the important uncertainty: the first operation happened even though the caller observed an error. No real network request, timeout, sleep, or random failure is needed. Record the keys sent so the test can explain the duplicate rather than merely noticing an unexpected count.

        With the broken client and a fresh keySequence, the two keys are op-1 and op-2, the returned task has identifier 2, and the store contains two tasks. Expecting one task deliberately fails. Calling this a network recovery because the promise resolved would ignore the feature contract. Failure before the write is a separate test case, because a reproduction that always fails before storage cannot reveal this defect.

        Deterministic lost-response transport and test key factory
        Codejavascript
        function loseFirstReply(store) {
          const keys = [];
          const send = async (command) => {
            keys.push(command.key);
            const result = store.create(command);
            if (keys.length === 1) throw new ResponseLost('reply lost after write');
            return result;
          };
          return { send, keys };
        }
        
        function keySequence() {
          let count = 0;
          return () => 'op-' + (++count);
        }
        

          Create the command once and replay that command

          Move command construction outside the loop. Both attempts now use the same key and title, so the second delivery finds the saved receipt and returns the first task. Object.freeze prevents this example command from being changed accidentally during its two attempts. It does not provide authentication or durable storage. The correction depends on the receiver honoring its replay contract; a stable client key by itself cannot prevent duplicate effects.

          ResponseLost is an explicit error from this simulated transport, not a claim that every real failure is retryable. The client retries at most once and propagates other errors. A production retry policy also needs to distinguish retryable responses, honor applicable retry guidance, and bound delays and total time. Keep those concerns visible in review without adding a generic retry framework to this tiny reproduction.

          Repaired client: one operation key for both attempts
          Codejavascript
          async function createTask(title, send, makeKey) {
            const command = Object.freeze({ key: makeKey(), title });
            for (let attempt = 0; attempt < 2; attempt++) {
              try {
                return await send(command);
              } catch (error) {
                if (!(error instanceof ResponseLost) || attempt === 1) throw error;
              }
            }
          }
          

            Verify stored effects, returned results, and errors

            Combine the code blocks above and these tests into retry-example.test.mjs. With Node.js 24, run node --test retry-example.test.mjs. The assertions use literal results, observed keys, and stored-task counts rather than computing expectations with another copy of the retry algorithm. Node documents assertions for equality, thrown errors, and rejected promises; remember to await the rejection check. Node.js 24.15.0 assertion documentation

            The eight tests passed when preparing this article. Moving key creation back inside the loop caused exactly two test failures: the lost-first-reply case returned identifier 2, and the exhausted-replies case left two tasks instead of one. The failure-before-write test still passed with that mutation. This confirms that it exercises a different path and cannot replace a post-write failure test.

            An exhausted request remains an uncertain outcome, not proof that nothing happened. The test intentionally observes a rejected promise together with one stored task. If a user retries later, the application must retain and reuse the original operation identity to recover its result. Calling createTask again generates a new action in this example; do not present it as durable deduplication across browser reloads or process restarts.

            Eight independent contract tests for the retry repair
            Codejavascript
            import test from 'node:test';
            import assert from 'node:assert/strict';
            
            test('lost reply repeats one command, not the write', async () => {
              const store = createStore();
              const transport = loseFirstReply(store);
              const result = await createTask('Review patch', transport.send, keySequence());
              assert.deepStrictEqual(result, { id: 1, title: 'Review patch' });
              assert.equal(store.tasks.length, 1);
              assert.deepStrictEqual(transport.keys, ['op-1', 'op-1']);
            });
            
            test('a normal reply needs one attempt', async () => {
              const store = createStore();
              let calls = 0;
              await createTask('Review patch', async command => {
                calls++;
                return store.create(command);
              }, keySequence());
              assert.equal(calls, 1);
              assert.equal(store.tasks.length, 1);
            });
            
            test('distinct actions with identical titles remain distinct', async () => {
              const store = createStore();
              const makeKey = keySequence();
              const send = async command => store.create(command);
              await createTask('Review patch', send, makeKey);
              await createTask('Review patch', send, makeKey);
              assert.deepStrictEqual(store.tasks.map(task => task.id), [1, 2]);
            });
            
            test('replaying the same key returns its saved result', () => {
              const store = createStore();
              const command = { key: 'op-1', title: 'Review patch' };
              const first = store.create(command);
              assert.strictEqual(store.create(command), first);
              assert.equal(store.tasks.length, 1);
            });
            
            test('a reused key with changed contents is rejected', () => {
              const store = createStore();
              store.create({ key: 'op-1', title: 'Review patch' });
              assert.throws(() => store.create({ key: 'op-1', title: 'Different task' }),
                            /key reused with different title/);
              assert.equal(store.tasks.length, 1);
            });
            
            test('failure before the write can retry the same command', async () => {
              const store = createStore();
              let calls = 0;
              await createTask('Review patch', async command => {
                if (++calls === 1) throw new ResponseLost('request did not arrive');
                return store.create(command);
              }, keySequence());
              assert.equal(calls, 2);
              assert.equal(store.tasks.length, 1);
            });
            
            test('exhausted replies reject even though one task exists', async () => {
              const store = createStore();
              let calls = 0;
              await assert.rejects(createTask('Review patch', async command => {
                calls++;
                store.create(command);
                throw new ResponseLost('reply lost');
              }, keySequence()), ResponseLost);
              assert.equal(calls, 2);
              assert.equal(store.tasks.length, 1);
            });
            
            test('permanent validation failures are not retried', async () => {
              const store = createStore();
              let calls = 0;
              await assert.rejects(createTask('', async command => {
                calls++;
                return store.create(command);
              }, keySequence()), TypeError);
              assert.equal(calls, 1);
              assert.equal(store.tasks.length, 0);
            });
            

              Review the persistence boundary before using the pattern

              Ask the assistant what happens if a process stops between storing a task and storing its receipt, or if two workers accept the same key simultaneously. The in-memory synchronous example cannot establish correctness for either deployment. A production design must coordinate the key claim, business write, and saved result through a suitable durable mechanism. External effects such as sending a message need their own treatment; a local receipt does not automatically cover another system.

              Also define the key scope and retention window. A receipt should not accidentally share another caller's result, and expiry can make a late retry behave like a new operation. Compare the request contents relevant to the contract when handling conflicts. These are design questions to resolve against the actual API and storage layer, not guarantees supplied by the illustrative Map or by an AI explanation.

                Frequently asked questions

                Does adding retries make a POST idempotent? No. The receiver must provide an appropriate replay contract, and the client must preserve the operation identity. Idempotency concerns repeated intended effects; it does not mean that no logging or other incidental activity can occur. MDN: idempotency and intended effects

                Should identical task titles share a key? No. Two intentional actions with the same text are still two actions in this contract. Reuse a key for attempts to complete one action, not as a global identifier for its title.

                What should I ask AI to check before accepting a retry patch? Ask it to enumerate failure before the write, failure after the write, conflicting replays, and exhausted attempts. Require concrete observations for both the result and the side-effect count, then execute those cases. The receiver's durability and concurrency behavior require separate integration evidence.

                  Sources & editorial notes

                  This article was authored with AI using primary-source research and an automated technical review. It has not been reviewed by a human. Code examples are illustrative; run and verify them in your own environment.

                  1. MDN: POST request method

                    Explains why successive identical POST requests may create additional resources. Checked October 24, 2026; this evergreen reference is not presented as a new announcement.

                  2. MDN: idempotency and intended effects

                    Defines idempotency by intended server effects and distinguishes it from absence of incidental activity. Checked October 24, 2026.

                  3. Node.js 24.15.0 assertion documentation

                    Node.js 24.15.0 assertion reference supporting the equality, exception, and awaited rejection checks in the runnable example. Checked October 24, 2026.

                  Put the reading into practice

                  Work through an existing repository, review AI suggestions, and test the decisions behind your changes.

                  Explore practice problems