DEV Community

delay.run
delay.run

Posted on Edited on

Case study: redesigning a failed-payment retry schedule that retried every card at 00:00 UTC

This is a hypothetical case study. The company and details are invented to illustrate a common design problem; the patterns and code are real.

The starting point

"Tallyhub" is a small subscription SaaS. When a renewal charge fails (expired card, insufficient funds, a bank's fraud check), it retries. The original implementation was a single nightly job:

# runs at 00:00 UTC via cron
for invoice in Invoice.objects.filter(status="past_due"):
    if invoice.attempts < 4:
        charge(invoice)
Enter fullscreen mode Exit fullscreen mode

It worked well enough until the team looked closely at three complaints.

1. Every retry happened at midnight UTC. For customers in Asia-Pacific that's mid-morning, which is fine. For customers on the US West Coast it's late afternoon. For some European customers it falls just after midnight, before many paydays have cleared. The retry timing was effectively random relative to when customers had money in their accounts.

2. Retries ignored when the failure happened. An invoice that failed at 23:58 was retried two minutes later. One that failed at 00:05 waited almost 24 hours. "Retry 1" meant anything from minutes to a day.

3. Overlapping runs double-charged twice in one quarter. A slow run (the payment API was degraded) was still going when an operator ran the job manually to "catch up". Both processes picked up the same invoices.

The redesign: one scheduled job per retry

The team replaced "scan everything nightly" with one scheduled job per invoice attempt. When a charge fails, the system decides when to retry that invoice and schedules exactly one job for that time.

Step 1: A retry policy relative to the failure

from datetime import timedelta

RETRY_OFFSETS = [timedelta(days=1), timedelta(days=3), timedelta(days=5), timedelta(days=7)]
Enter fullscreen mode Exit fullscreen mode

Retry 1 is now always about a day after the failure, regardless of when it happened.

Step 2: Land retries at a sensible local time

Rather than an arbitrary UTC moment, retries are moved to mid-morning in the customer's billing timezone, with jitter so thousands of invoices don't all fire at 10:00:00.

import random
from datetime import datetime, time
from zoneinfo import ZoneInfo

def next_retry_at(failed_at_utc: datetime, attempt: int, tz_name: str) -> datetime:
    tz = ZoneInfo(tz_name or "UTC")
    target = (failed_at_utc + RETRY_OFFSETS[attempt]).astimezone(tz)
    local = datetime.combine(target.date(), time(10, 0), tzinfo=tz)
    local += timedelta(minutes=random.randint(0, 90))     # spread the load
    return local.astimezone(ZoneInfo("UTC"))
Enter fullscreen mode Exit fullscreen mode

Whether mid-morning local time actually improves recovery is something to measure, not assume. The team logged attempt time and outcome so they could compare later (more on that below).

Step 3: Schedule exactly one job, with an idempotency key

def on_charge_failed(invoice, attempt):
    if attempt >= len(RETRY_OFFSETS):
        mark_uncollectible(invoice)
        return
    run_at = next_retry_at(now_utc(), attempt, invoice.customer.timezone)
    scheduler.schedule(
        run_at=run_at,
        url="https://api.tallyhub.example/internal/retry-charge",
        body={"invoice_id": invoice.id, "attempt": attempt + 1},
        idempotency_key=f"retry:{invoice.id}:{attempt + 1}",
    )
Enter fullscreen mode Exit fullscreen mode

The scheduler can be a database table plus a worker, a job queue with delayed jobs, or an HTTP scheduling service such as delay.run. The important property is the same: one job, one time, one key.

Step 4: Make the handler safe to run twice

Even with a unique key, the handler assumes it might be called twice (retries, redeploys, operator mistakes). It takes a row lock and re-checks state before charging:

@transaction.atomic
def retry_charge(invoice_id: int, attempt: int):
    inv = Invoice.objects.select_for_update().get(id=invoice_id)
    if inv.status != "past_due" or inv.attempts >= attempt:
        return "skipped"                     # paid already, or this attempt already ran
    inv.attempts = attempt
    inv.save(update_fields=["attempts"])
    result = charge(inv, idempotency_key=f"charge:{inv.id}:{attempt}")
    ...
Enter fullscreen mode Exit fullscreen mode

Two layers of protection: the database check stops duplicate work, and the payment provider's idempotency key stops duplicate charges if the same request is sent twice.

Step 5: Cancel retries when they're no longer needed

If the customer updates their card and pays manually, pending retries should disappear. Store the scheduled job's ID on the invoice and cancel it when the invoice is paid. The handler's state check is the backstop if cancellation is missed.

What changed (and what they measured)

Without inventing numbers, here's what the team set out to measure, and what you should measure too if you try this:

  • Recovery rate per attempt number, before and after.
  • Recovery rate by local hour of attempt, to test whether the 10:00 choice helps or whether another time is better for their customers.
  • Duplicate-charge incidents (target: zero).
  • Load shape on the payment API: one midnight spike versus a spread-out curve.

The design win doesn't depend on the numbers, though. The midnight batch made it impossible to even ask whether timing mattered, because every retry happened at the same moment. Per-invoice scheduling turned retry timing into something you can tune.

Testing the schedule before it touches real cards

A retry schedule is easy to get subtly wrong, so the team tested it without a payment provider in the loop. Three checks did most of the work:

  1. Freeze the clock. The function that computes retry times takes now and the customer's timezone as arguments instead of reading the system clock. Unit tests then cover the awkward cases: a failure at 23:59 local time, a customer in UTC+13, and a retry that lands on a daylight-saving change.
  2. Replay a real month. They exported last month's failed invoices (IDs, timestamps and timezones only, no card data) and ran them through the new scheduler in a dry-run mode that logs planned retry times instead of creating jobs. A histogram of the planned times showed the midnight spike was gone.
  3. Fire every job twice in staging. The handler was called twice for each retry with the same idempotency key. The second call had to be a no-op, with one charge attempt and one log line saying "already handled".

None of this needs special tooling, and it caught two bugs before launch: one off-by-one-day error around month ends, and a missing timezone that defaulted to UTC.

Lessons that generalize

  1. "Scan everything on a schedule" hides per-item timing. If each item has its own clock, give it its own job.
  2. Relative delays beat fixed times for anything triggered by an event.
  3. Every money-moving job needs two idempotency layers: your own state check and the provider's key.
  4. Cancellation is a feature. Design it in, but don't rely on it alone.
  5. Log the inputs you'll want to tune later, such as attempt time, local hour and outcome, from day one.

Related reading

How does your product schedule payment retries: a nightly batch, per-invoice jobs, or does your payment provider handle it all? I'd like to hear what's worked, and whether anyone has real data on retry timing.

Top comments (13)

Collapse
 
fintechrailsdigest profile image
Fintech Rails Digest •

the midnight-utc detail is what sells the whole redesign: an invoice that failed at 23:58 got retried two minutes later while one from 00:05 waited nearly a day, and both counted as "on schedule". moving the anchor from when the job runs to when the failure happened is the shift most retry systems still miss. the idempotency key keyed on invoice plus attempt is the other half worth copying, because the manual "catch up" run double-charging twice in a quarter is exactly how these outages actually start. one thing i'd add: a local mid-morning slot can still collide with a bank or gateway maintenance window, so the jitter window is worth logging against gateway status too.

Collapse
 
delayrun profile image
delay.run •

Mid-morning can still slam into a gateway maintenance window. We log retries against the provider status page for that. Anchoring to when the failure happened, plus a bit of jitter, is still what most schedules skip.

Collapse
 
fintechrailsdigest profile image
Fintech Rails Digest •

logging retries against the provider status page is the part most teams never wire up, the retry usually fails twice before anyone checks the status feed. one layer worth adding: treat a spike in a specific decline code during a window as evidence, not just raw failure count, so a maintenance window that only affects one gateway gets blamed on the right one instead of poisoning the card-level retry decision for the whole queue.

Thread Thread
 
delayrun profile image
delay.run •

Agreed, raw failure count alone will blame every card when only one gateway is sick. Grouping the spike by decline code (and by gateway when you have more than one) keeps a single-bank maintenance window from poisoning the whole queue. Do you bucket by code only, or also by acquirer / MID when those show up in the decline payload?

Thread Thread
 
fintechrailsdigest profile image
Fintech Rails Digest •

code first since it's always present, then MID when the payload carries it, because acquirer routing is where maintenance windows actually hide. the trap with MID-level bucketing is sparse data: a handful of declines on one MID can look like an outage when it's really one niche card program, so we only alert on a MID's rate once it clears a minimum volume for the window.

Thread Thread
 
delayrun profile image
delay.run •

A volume floor makes sense, especially when one card program can dominate a quiet MID. I'd keep low-volume buckets visible as 'insufficient data' rather than healthy, so the alert suppression doesn't become a blind spot. Do you use a longer window for those MIDs, or leave them for manual review?

Thread Thread
 
fintechrailsdigest profile image
Fintech Rails Digest •

both, actually: low-volume MIDs get a longer rolling window, a month instead of a week, so a single weekend spike can't trip anything, and they also land in a weekly manual-review queue. the queue matters because some MIDs are low-volume for a reason, seasonal merchants or newly onboarded cards, and no automated threshold will tell you which case you're looking at.

Thread Thread
 
delayrun profile image
delay.run •

Seasonal merchants are a good reason to keep a person in that loop. A monthly window on a shop that only really trades in December is mostly measuring silence for eleven months. Do reviewers tag a MID as seasonal or newly onboarded so it stops showing up every week, or does the queue start fresh each time?

Thread Thread
 
fintechrailsdigest profile image
Fintech Rails Digest •

in most shops the queue just starts fresh each review cycle, and that's exactly why seasonal merchants look like noise: a December-heavy MID gets a January review that sees near-zero volume and flags it as dormant, then a December review that sees a 40x jump and flags it as anomalous. tagging it seasonal or newly-onboarded up front is the fix, but it needs someone who actually knows the merchant's business to set the tag, which is your point about keeping a person in the loop. a lot of teams end up doing that tagging manually anyway, just late and inconsistently.

Thread Thread
 
delayrun profile image
delay.run •

"Late and inconsistently" is painfully accurate, usually right after the same merchant has been flagged for the third time. Setting the tag at onboarding, while someone still remembers what the business actually sells, looks like the cheapest fix.

Thread Thread
 
fintechrailsdigest profile image
Fintech Rails Digest •

onboarding is the right moment for the first guess, since it's the only time someone still knows what the business actually sells. but the tag can't be a one-time stamp: seasonality drifts, and the MID's own decline pattern ends up being the more honest source. a card that fails every December looks like noise in the annual review and obvious in the monthly one. so tag at onboarding, then let the retry data correct it.

Collapse
 
paulhartness profile image
Paul Hartness •

The midnight-batch failure modes land cleanly — especially the 23:58 retry two minutes later versus the 00:05 wait of almost a day. Anchoring each attempt to the failure time is the bit most DIY schedules miss.

One caveat when you measure recovery by local hour: hard declines (lost_card, stolen_card, incorrect_number and a few others) get no live retry under Stripe either, and expired_card still gets charged even though a retry never updates the number. Timing only moves the soft ones, so split the comparison by decline code or the hard cases blur the local-hour result.

Stripe also doesn't publish a recovery rate for Smart Retries versus a fixed schedule, so the "measure, don't assume" line is the right one there too:

retryfix.com/blog/smart-retries-vs...

Collapse
 
delayrun profile image
delay.run •

Hard declines are the right split — lost_card, stolen_card, incorrect_number shouldn't be in the timing comparison at all, or the local-hour win looks smaller than it is. expired_card is the awkward middle: retrying without a new number just burns the same decline again.