Deliver Messages in Fixed Batches with Per-Message Retry Delays
DecagonSoftware Engineer · Technical ScreenLast updated
Send a list of outbound messages to a provider that only accepts five per call, then retry failed messages only after the provider's own per-message cooldown has elapsed. Tests chunking, scheduling retries by readiness time instead of busy-waiting, and handling partial batch failures cleanly.
You need to deliver a list of outbound notification messages through a third-party messaging provider. The provider's API only accepts batches of at most 5 messages per call, and for each message in a batch it returns either success or a failure accompanied by a cooldown, in seconds, that must pass before that specific message may be retried.
Clarifying questions worth asking
- What does the provider's interface look like concretely — a function you call directly, or a network call? What exactly is in its response for each message?
- Can multiple batches be sent concurrently, or should the solution assume one call in flight at a time?
- Should results be returned to the caller in the original input order, and is there a cap on total retry attempts or an overall deadline?
Part 1 — Send everything in batches of at most 5
Deliver every message in messages using the fewest calls the 5-per-call limit permits, making sure nothing is skipped, nothing is sent twice, and the last, possibly smaller, group is handled correctly.
def send_all(messages: list, send_batch) -> list:
"""send_batch takes a list of up to 5 messages and returns, in the same
order, one (ok: bool, payload) pair per message."""
Part 2 — Retry failures after their own cooldown
Some messages fail. A failed message's response carries a cooldown in seconds measured from the moment that response was received; the message may not be retried before that cooldown elapses. Extend the design so every message eventually succeeds (or exhausts a retry limit), different messages' cooldowns don't block each other, and the process never busy-waits spinning on a clock when nothing is ready yet.
def send_with_retries(messages: list, send_batch, max_attempts: int = 5) -> list:
"""Like send_all, but a failed message is retried once its own cooldown
has elapsed, while other messages that are ready keep flowing in batches
of 5. payload on failure is the cooldown in seconds."""
Worked example
A fake provider where message i fails its first i % 3 attempts, each failure reporting a 10 ms cooldown, then succeeds:
messages = list(range(12))
# every message eventually reports (True, "ok:<i>"), after 1, 2, or 3 attempts
# depending on i % 3
Constraints
- Up to
10^4messages. - A message's cooldown is a non-negative number of seconds, typically small (well under a second in tests, but should work unmodified for real multi-second cooldowns).
max_attemptsbounds how many times a single message may be sent in total, including its first attempt; a message that is still failing once it hits the cap is reported as a permanent failure rather than retried forever.
Hints
Hint 1
Keep 'split a list into chunks of 5' as its own tiny, separately testable function — both parts need it, and Part 2 adds complexity on top rather than changing it.
Hint 2
Track each failed message's own 'not ready before this timestamp,' not a single shared retry clock for everything.
Hint 3
A min-heap ordered by readiness time gives you 'what's the next thing that can retry, and when' without scanning every pending message.
Hint 4
When nothing is immediately ready, sleep exactly until the earliest pending readiness time rather than polling in a loop — the gap between 'ready queue empty' and 'waiting queue has the next thing' is where a busy-wait bug usually hides.
Follow-up questions
- How would you adapt this if the provider allowed several batches to be in flight concurrently instead of one call at a time?
- How would you add an overall deadline across the whole send, after which any still-pending messages are reported as timed out instead of retried further?
- If cooldowns could be minutes or hours long in production, would you still want this process to hold the sleeping thread, or would you persist pending retries somewhere and use a separate scheduler? What changes?
- How would you test this without real time passing, beyond injecting now and sleep as done here?
Solution walkthrough is for members
Get the full walkthrough with reference code and complexity analysis, plus Kitty Coach at 300 messages a day. Hints, the code runner and community answers stay free.