6
submitted 2 days ago* (last edited 2 days ago) by MasterPick520@programming.dev to c/javascript@programming.dev

Most of us implement idempotency the same way the first time. Check whether you've seen the key, and if not, do the work and store the key so the next request knows:

app.post("/charge", async (req, res) => {
  const key = req.get("Idempotency-Key");
  if (await redis.get(key)) { 
      return res.json(JSON.parse(await redis.get(`${key}:result`)));
  }

  const result = await chargeCard(req.body);      // real money leaves here
  await redis.set(key, "done");
  await redis.set(`${key}:result`, JSON.stringify(result));
  res.json(result);
});

It reviews fine. Then two things happen in production.

Two retries arrive at the same moment. Both reach the get before either reaches the set, so both charge the card. A load test finds this one if you go looking.

The process dies after chargeCard and before redis.set. The money moved, the key was never written, and the retry charges again. This one you can't easily reproduce on purpose, and it happens on its own during any deploy.

Fixing the first properly means the check and the write have to be one atomic operation, so exactly one caller wins. That creates the next problem: the winner can die holding the key, so the claim needs a lease that expires. Which creates another: the "dead" worker might just be slow, wake up, and overwrite the real result with its stale one. So the write needs a fencing token that the reclaimer invalidates.

I got tired of rewriting that, so it's a library now:

import express from "express";
import { idempotency } from "idemkit/express";
import { RedisBackend } from "idemkit/redis";

const app = express();
app.use(idempotency({ backend: await RedisBackend.fromUrl(process.env.REDIS_URL) }));

app.post("/charge", async (req, res) => {
  res.status(201).json(await chargeCard(req.body));   // runs once per Idempotency-Key
});

Same thing works for queue consumers (SQS, Kafka, RabbitMQ, BullMQ) and plain function calls. Redis, Postgres, Mongo, DynamoDB or in memory. Zero dependencies, Apache-2.0.

https://github.com/idemkit/idemkit

If you find it useful, a star would be much appreciated.

4

I idemkit after cleaning up duplicate charges one too many times.

The version everyone writes checks whether a key has been seen and replays the stored response. Two requests a millisecond apart both find nothing and both charge the card. And if the worker dies between charging and recording it, the retry charges again. Neither reproduces locally.

idemkit does it properly: an atomic claim instead of check-then-act, a lease that expires on the storage server’s clock, and a fencing token so a stalled worker can’t overwrite a good result.

from idemkit import idempotent, RedisBackend, MethodConfig

@idempotent(
    backend=RedisBackend.from_url(“redis://localhost:6379”),
    config=MethodConfig(key_fields=[“order_id”]), 
) 
async def charge(*, order_id, amount): 
    return await payments.charge(order_id, amount) 

One core, three ways to use it: middleware for FastAPI/Flask/Django, a queue consumer wrapper or @idempotent on any function. Backends are Redis, Postgres, Mongo, DynamoDB, or in-memory for tests.

pip install idemkit, Apache-2.0: https://github.com/idemkit/idemkit

If you find it useful, I’d appreciate a star. It’s new, so visibility helps a lot right now.

4
submitted 1 week ago* (last edited 1 week ago) by MasterPick520@programming.dev to c/opensource@programming.dev

I built idemkit after cleaning up duplicate charges one too many times.

The version everyone writes checks whether a key has been seen and replays the stored response. Two requests a millisecond apart both find nothing and both charge the card. And if the worker dies between charging and recording it, the retry charges again. Neither reproduces locally.

idemkit does it properly: an atomic claim instead of check-then-act, a lease that expires on the storage server’s clock, and a fencing token so a stalled worker can’t overwrite a good result.

from idemkit import idempotent, RedisBackend, MethodConfig 

@idempotent(
    backend=RedisBackend.from_url("redis://localhost:6379"), 
    config=MethodConfig(key_fields=["order_id"]), 
) 
async def charge(*, order_id, amount): 
    return await payments.charge(order_id, amount)

One core, three ways to use it: middleware for FastAPI/Flask/Django, a queue consumer wrapper or @idempotent on any function. Backends are Redis, Postgres, Mongo, DynamoDB, or in-memory for tests.

pip install idemkit, Apache-2.0: https://github.com/idemkit/idemkit

If you find it useful, I'd appreciate a star. It's new, so visibility helps a lot right now.

11
submitted 1 week ago* (last edited 1 week ago) by MasterPick520@programming.dev to c/programming@programming.dev

I idemkit after cleaning up duplicate charges one too many times.

The version everyone writes checks whether a key has been seen and replays the stored response. Two requests a millisecond apart both find nothing and both charge the card. And if the worker dies between charging and recording it, the retry charges again. Neither reproduces locally.

idemkit does it properly: an atomic claim instead of check-then-act, a lease that expires on the storage server’s clock, and a fencing token so a stalled worker can’t overwrite a good result.

from idemkit import idempotent, RedisBackend, MethodConfig 

@idempotent(
    backend=RedisBackend.from_url("redis://localhost:6379"), 
    config=MethodConfig(key_fields=["order_id"]), 
) 
async def charge(*, order_id, amount): 
    return await payments.charge(order_id, amount)

One core, three ways to use it: middleware for FastAPI/Flask/Django, a queue consumer wrapper or @idempotent on any function. Backends are Redis, Postgres, Mongo, DynamoDB, or in-memory for tests.

pip install idemkit, Apache-2.0: https://github.com/idemkit/idemkit

If you find it useful, I'd appreciate a star. It's new, so visibility helps a lot right now.

[-] MasterPick520@programming.dev 1 points 1 week ago

That's great to hear! If you have any thoughts about what is missing or can be improved, please let me know :)

14
submitted 1 week ago* (last edited 1 week ago) by MasterPick520@programming.dev to c/python@programming.dev

I built idemkit after cleaning up duplicate charges one too many times.

The version everyone writes checks whether a key has been seen and replays the stored response. Two requests a millisecond apart both find nothing and both charge the card. And if the worker dies between charging and recording it, the retry charges again. Neither reproduces locally.

idemkit does it properly: an atomic claim instead of check-then-act, a lease that expires on the storage server's clock, and a fencing token so a stalled worker can't overwrite a good result.

from idemkit import idempotent, RedisBackend, MethodConfig 

@idempotent(
    backend=RedisBackend.from_url("redis://localhost:6379"), 
    config=MethodConfig(key_fields=["order_id"]), 
) 
async def charge(*, order_id, amount): 
    return await payments.charge(order_id, amount)

One core, three ways to use it: middleware for FastAPI/Flask/Django, a queue consumer wrapper or @idempotent on any function. Backends are Redis, Postgres, Mongo, DynamoDB, or in-memory for tests.

pip install idemkit, Apache-2.0: https://github.com/idemkit/idemkit

If you find it useful, I'd appreciate a star. It's new, so visibility helps a lot right now.

MasterPick520

0 post score
0 comment score
joined 1 week ago