DEV Community

delay.run
delay.run

Posted on Edited on

Make scheduled job handlers idempotent: safe to run twice

Reliable HTTP cron and delayed-job scheduling with automatic retries and full run logs — delay.run

Any system that calls your endpoint on a schedule, or retries it after a failure, will sooner or later call it twice for the same piece of work. A network timeout after your handler finished, a retry after a slow response, two servers firing the same cron: each of these runs the job again. If your handler sends an email or charges a card, "again" is a real problem. The fix is to make the handler idempotent: running it twice has the same effect as running it once.

Why duplicates are unavoidable

Consider a scheduler calling POST /jobs/send-invoices. Your handler takes 35 seconds. The caller's timeout is 30 seconds, so it records a failure and retries. Your first run finishes anyway. Now the invoices go out twice.

You can't fix this purely on the caller's side. Without the caller knowing exactly what your handler completed, "at least once" delivery is the only safe guarantee, and handling duplicates is the handler's job.

Technique 1: an idempotency key

Give each unit of work a unique key and record it when you process it. If the key has been seen, skip the work.

# Flask + PostgreSQL example
from flask import Flask, request
import psycopg

app = Flask(__name__)

@app.post("/jobs/send-reminder")
def send_reminder():
    payload = request.get_json()
    key = payload["job_key"]  # e.g. "reminder:user42:2026-11-01"
    with psycopg.connect(DSN) as conn:
        cur = conn.execute(
            "INSERT INTO processed_jobs (key) VALUES (%s) ON CONFLICT DO NOTHING",
            (key,),
        )
        if cur.rowcount == 0:
            return {"status": "duplicate, skipped"}, 200
        send_email(payload["user_id"])
    return {"status": "sent"}, 200
Enter fullscreen mode Exit fullscreen mode
CREATE TABLE processed_jobs (
  key text PRIMARY KEY,
  processed_at timestamptz DEFAULT now()
);
Enter fullscreen mode Exit fullscreen mode

The unique constraint does the hard work: if two copies arrive at the same moment, only one insert succeeds.

Return a 2xx for duplicates. If you return an error, the caller retries again.

Choosing the key

The key should describe the work, not the delivery attempt:

  • Good: invoice-run:2026-11, reminder:user42:trial-day-3
  • Bad: a random UUID generated per request, a timestamp of when the call arrived

For recurring jobs, include the period: daily-report:2026-11-01. Then a second call on the same day is skipped, and tomorrow's call runs normally.

Technique 2: make the work itself naturally idempotent

Some operations are safe to repeat by design:

-- Not idempotent
UPDATE accounts SET credits = credits + 100 WHERE id = 42;

-- Idempotent
UPDATE accounts SET plan = 'pro', renewed_until = '2026-12-01' WHERE id = 42;
Enter fullscreen mode Exit fullscreen mode

Setting a value is safe; incrementing it isn't. Upserts, "mark as done" flags and "recalculate from source" jobs are all naturally safe to run again.

Idempotent handlers still need a scheduler that will retry them when the first attempt fails. delay.run does that retry and keeps the log, so a double-run is something you can see and design for.

Technique 3: claim, then process

For batch jobs, mark each item as claimed in the same step as selecting it:

UPDATE emails
SET status = 'sending', claimed_at = now()
WHERE id IN (
  SELECT id FROM emails
  WHERE status = 'queued'
  ORDER BY id
  LIMIT 100
  FOR UPDATE SKIP LOCKED
)
RETURNING id, recipient;
Enter fullscreen mode Exit fullscreen mode

Two overlapping runs each get different rows, and a re-run picks up only what's left.

External side effects

The idempotency key protects your database, but what about the payment provider or email API you call? Many such APIs accept their own idempotency key header. Pass your job key through so the provider can also drop duplicates:

requests.post(
    "https://api.payments.example/charges",
    headers={"Idempotency-Key": key},
    json={...},
)
Enter fullscreen mode Exit fullscreen mode

If an API has no such option, record "sent" in your database before marking the job complete, and accept that a crash in between could still send twice. Design for the rare duplicate to be harmless.

Respond fast, work later

A long-running handler makes duplicates more likely, because callers time out. Accept the request, store the work, and return 202 Accepted quickly, then process it in a background worker. The idempotency key goes with it.

Test it

The simplest test: call the endpoint twice with the same payload, in parallel.

for i in 1 2; do
  curl -s -X POST localhost:5000/jobs/send-reminder \
    -H 'content-type: application/json' \
    -d '{"job_key":"test-1","user_id":42}' &
done; wait
Enter fullscreen mode Exit fullscreen mode

One response should say "sent" and the other "duplicate".

Where retries come from

A hosted scheduler like delay.run calls your URL on a cron schedule or after a delay and retries failed jobs with backoff, logging each attempt. Retries are what make jobs reliable, and idempotent handlers are what make retries safe.

Related reading

Top comments (10)

Collapse
 
beusebiu profile image
Eusebiu Balan •

One trap with the framework-level version of this, in Laravel at least: withoutOverlapping() keeps its lock for 24 hours by default. If a deploy kills the run halfway, the lock stays and the job quietly skips every run until it expires. My inbox sync runs every 15 minutes, so I set its lock to 10.

And that's why the lock alone isn't enough. A run cut off halfway gets picked up by the next one, so the handler still has to be fine doing that work twice.

Collapse
 
delayrun profile image
delay.run •

Great catch, and it bites a lot of people. A lock that outlives the job is just a silent outage. Your rule of a lock a bit shorter than the interval is a good default. Pairing it with a "last successful run" timestamp you alert on also helps, so a stuck lock shows up in minutes instead of a day. And yes, the lock only stops two runs at once. It doesn't make a half-finished run safe to repeat, which is the whole point of the post.

Collapse
 
paul_irolla_6b5ae261d0224 profile image
Paul Irolla •

Write the effect key before the side effect, not after. If the job dies between the send and the record, the retry sends again. A row saying 'sent for run X', checked first, turns the second run into a no-op.

Collapse
 
delayrun profile image
delay.run •

Agreed on checking the key first. One nuance is that if you write "sent" before sending and the job dies between the two, the retry skips it and the email never goes out. What's worked for us is to claim the key with a "pending" status and the run id, do the side effect, then flip it to "done". A retry that finds an old "pending" row knows it should check or resend. If the provider accepts an idempotency key, passing the same key through closes the gap completely.

Collapse
 
paul_irolla_6b5ae261d0224 profile image
Paul Irolla •

@delayrun Agreed on pending plus run id. The part I'd keep strict is the retry: a pending row older than the job timeout gets checked against the provider before anything is resent, never resent blind. With a provider idempotency key that check becomes a lookup, not a guess.

Collapse
 
delayrun profile image
delay.run •

Yes, "check, never resend blind" is the rule I'd keep too. For providers without an idempotency key or a lookup API, the honest fallback is to park the stale pending row for a human instead of guessing, because a missed email is usually cheaper than a duplicate charge. Have you hit a provider where there was no way to check?

Collapse
 
paul_irolla_6b5ae261d0224 profile image
Paul Irolla •

@delayrun A retry that resends blind is the one that double-charges, so the check against the provider has to come first, every time.

Collapse
 
delayrun profile image
delay.run •

That ordering also needs to survive a timeout on the provider lookup itself: an inconclusive check should leave the attempt unresolved, not unlock another charge. I'd give those rows a separate age alert so they can't sit pending forever. Do you put a time limit on automated reconciliation before handing them to an operator?

Collapse
 
paul_irolla_6b5ae261d0224 profile image
Paul Irolla •

@delayrun Yes, I'd put a clock on every unresolved row. An inconclusive lookup keeps the attempt open; once the limit passes, an operator takes it, never the retry loop.

Collapse
 
delayrun profile image
delay.run •

Same rule here. The one thing I'd add is recording the handoff on the row itself, who picked it up and when, so two operators don't end up reconciling the same charge from different screens.

⌂ 换网址