← Усі задачіNode.js · Senior

Idempotency key для платежів

Клієнт не отримав відповідь на «списати 100 грн» через таймаут і повторює запит. Щоб гроші не списалися двічі, такі API приймають заголовок Idempotency-Key. Напишіть withIdempotency(handler, { ttlMs, now }), що повертає (key, payload) => Promise.

- Перший виклик із ключем запускає handler(payload); паралельні виклики з тим самим ключем, поки він виконується, отримують той самий проміс. - Успішний результат запам'ятовується на ttlMs і повертається без повторного виклику. - Помилка не запам'ятовується: наступний виклик із ключем спробує знову. - Той самий ключ з іншим payload (порівняння через JSON.stringify) відхиляється з Idempotency key reused with a different payload.

Тести (6)

  1. let t = 0;
    const charge = fn(async (p) => ({ id: 'ch_1', amount: p.amount }));
    const pay = withIdempotency(charge, { ttlMs: 60_000, now: () => t });
    const first = await pay('key-1', { amount: 100 });
    const second = await pay('key-1', { amount: 100 });
    expect(second).toEqual(first);
    expect(charge).toHaveBeenCalledTimes(1);
  2. let t = 0;
    const charge = fn(async (p) => { await wait(20); return p.amount; });
    const pay = withIdempotency(charge, { ttlMs: 60_000, now: () => t });
    const results = await Promise.all([pay('k', { amount: 5 }), pay('k', { amount: 5 }), pay('k', { amount: 5 })]);
    expect(results).toEqual([5, 5, 5]);
    expect(charge).toHaveBeenCalledTimes(1);
  3. let t = 0;
    const pay = withIdempotency(async (p) => p.amount, { ttlMs: 60_000, now: () => t });
    await pay('k', { amount: 100 });
    await expect(pay('k', { amount: 999 })).rejects.toThrow('different payload');
  4. let t = 0;
    let calls = 0;
    const pay = withIdempotency(async () => { calls++; if (calls === 1) throw new Error('gateway timeout'); return 'ok'; }, { ttlMs: 60_000, now: () => t });
    await expect(pay('k', {})).rejects.toThrow('gateway timeout');
    expect(await pay('k', {})).toBe('ok');
    expect(calls).toBe(2);
  5. let t = 0;
    const charge = fn(async () => 'ok');
    const pay = withIdempotency(charge, { ttlMs: 1000, now: () => t });
    await pay('k', {});
    t = 999;
    await pay('k', {});
    expect(charge).toHaveBeenCalledTimes(1);
    t = 1000;
    await pay('k', {});
    expect(charge).toHaveBeenCalledTimes(2);
  6. let t = 0;
    const charge = fn(async (p) => p.n);
    const pay = withIdempotency(charge, { ttlMs: 1000, now: () => t });
    expect(await pay('a', { n: 1 })).toBe(1);
    expect(await pay('b', { n: 2 })).toBe(2);
    expect(charge).toHaveBeenCalledTimes(2);