Webhook Dispatcher: Retries, Ordering and Pausing
MediumAI-assisted coding · AI-Assisted Feature Work
Webhook Dispatcher: Retries, Ordering and Pausing
You've joined the platform team that owns the webhook dispatcher: the service that delivers event notifications to customers' HTTP endpoints, retrying failures with backoff and giving up ("dead-lettering") when an endpoint stays broken. Support has two retry-policy complaints, and the roadmap has two features behind them. Time is simulated: every operation carries an integer ts in seconds, and ts never decreases.
Codebase
| File | What it does |
|---|---|
webhooks/api.py | run(operations) entry point and the Dispatcher that routes each operation |
webhooks/service.py | WebhookService: register, enqueue, process (run due attempts), status |
webhooks/delivery_queue.py | DeliveryQueue: stores deliveries and decides which are due |
webhooks/policy.py | Retry policy: success, retryable statuses, backoff delay, attempt limit |
webhooks/transport.py | Simulated HTTP: returns the scripted status for an attempt |
webhooks/registry.py | EndpointRegistry: registered endpoints |
webhooks/models.py | Endpoint and Delivery dataclasses, delivery states |
webhooks/config.py | Policy constants (max attempts, base and maximum delay) |
webhooks/errors.py | WebhookError and every error code |
tests/ | Existing tests (several of them currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh dispatcher and returns one result per operation. Every operation is [name, ts, ...args]. Errors are returned as "error: <code>" strings, never raised.
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
register | endpoint_id, url | true | invalid arguments (empty or non-string id), invalid url (must start with http:// or https://), endpoint exists |
enqueue | endpoint_id, event_id, outcomes | "queued" | unknown endpoint, duplicate event, invalid outcomes |
process | none | list of attempt records (strings), in the order the attempts ran | none |
status | event_id | {"state", "attempts", "next_attempt_at"} | unknown event |
dead_letters | none | event ids that were dead-lettered, in the order they were | none |
disable | endpoint_id | true | not supported yet (Part 3) |
enable | endpoint_id, replay_dead | true | not supported yet (Part 3) |
- An unknown operation name returns
"error: unknown operation". A wrong number of arguments or a non-integertsreturns"error: invalid arguments". - Event ids are unique across all endpoints. An event is delivered to the one endpoint it was enqueued for.
outcomesscripts the simulated HTTP status of each attempt: attempt *n* getsoutcomes[n-1], and the last entry repeats once the list runs out. It must be a non-empty list of integers, each0(a timeout) or in100..599; otherwiseinvalid outcomes. enqueuemakes the event due at its ownts. Nothing is attempted untilprocessruns.processat timetsattempts every event whosedue_at <= ts(earliestdue_atfirst, ties by enqueue order). An attempt increments the event's attempt count and then:- status
200..299: the event is delivered; record"evt-1 #3 200 delivered". - status
500..599,408,429or0is retryable. If attempts remain, the next attempt is due atts + delayand the record is"evt-1 #1 503 retry at 130". - any other status (e.g.
404,410) is permanent: the event is dead immediately; record"evt-1 #1 404 dead". - a retryable failure on the last allowed attempt also ends in
"... dead". A retry is always due in the future, so an event is attempted at most once perprocesscall. - Retry policy (see
config.py): an event gets at most 6 attempts in total. The wait after the first failed attempt is 30 seconds, and it doubles after every further failure, never exceeding 300 seconds: waits of 30, 60, 120, 240, 300. statusreturns the event'sstate("pending","delivered"or"dead"), the number ofattemptsmade so far, andnext_attempt_at: itsdue_atwhile pending, otherwisenull.
Worked example
[["register", 0, "shop", "https://shop.example/hooks"],
["enqueue", 0, "shop", "evt-1", [503, 503, 200]],
["process", 0],
["process", 30],
["process", 89],
["process", 90],
["status", 90, "evt-1"]]returns
[true, "queued",
["evt-1 #1 503 retry at 30"],
["evt-1 #2 503 retry at 90"],
[],
["evt-1 #3 200 delivered"],
{"state": "delivered", "attempts": 3, "next_attempt_at": null}]Part 1 — Retries are off
Goal: make retries follow the policy above.
Customers and support have reported:
- "Our server returns a 500 once and the retry comes a full minute later, not 30 seconds."
- "An endpoint that was down for hours received seven attempts per event. We promised six."
Rules
- The wait after the first failed attempt is 30 seconds, then 60, 120, 240 and 300 (the cap).
- An event is dead-lettered after its 6th attempt fails; there is no 7th attempt.
- Everything else in the table above keeps working as described.
Part 2 — Per-endpoint ordering
Goal: a customer's endpoint must see its events in the order they were enqueued, even when an earlier event is being retried.
Rules
- For one endpoint, an event is not attempted while any earlier-enqueued event for the same endpoint is still pending (not yet delivered or dead). It waits behind that event, even if its own
due_athas passed. - As soon as the earlier event is delivered or dead-lettered, the next one is eligible. If it is due at the current
tsit is attempted in the sameprocesscall. - Endpoints do not block each other.
- In one
processcall, repeatedly pick, among each endpoint's first pending event whosedue_at <= ts, the one with the smallest(due_at, enqueue order)and attempt it, until none is left. - A waiting event reports
state: "pending",attempts: 0and its owndue_atasnext_attempt_at(blocking is not reflected instatus).
Example: evt-1 (outcomes [500, 200]) and evt-2 ([200]) are enqueued for shop at ts 0. process at 0 returns ["evt-1 #1 500 retry at 30"] (evt-2 waits). process at 30 returns ["evt-1 #2 200 delivered", "evt-2 #1 200 delivered"].
Part 3 — Pause and resume an endpoint
Goal: customers need to pause an endpoint during maintenance, and later resume it, optionally re-sending events that had already given up.
Rules
disable(endpoint_id)pauses the endpoint:processmakes no attempts for it. Errors:unknown endpoint,already disabled.- While disabled,
enqueuestill accepts events for it. Its pending events (new ones and ones waiting for a retry) keep their attempt counts, andstatusreports them asstate: "held"withnext_attempt_at: null. Delivered and dead events are unaffected. enable(endpoint_id, replay_dead)resumes the endpoint;replay_deadmust betrueorfalse(anything else isinvalid arguments, checked before the other errors). Errors:unknown endpoint,not disabled.- On enable, every pending event of the endpoint becomes due at the enable
ts(it keeps its attempt count, so the retry schedule continues where it stopped). - With
replay_dead: true, the endpoint's dead-lettered events are also revived: state back topending, attempts reset to 0, due at the enablets, and removed fromdead_letters. Withfalsethey stay dead. - Ordering from Part 2 holds across all of this: a revived event goes back to its original position (by enqueue order) among the endpoint's pending events and is attempted before later events.
- Other endpoints are completely unaffected.
Example: evt-1 ([404]) is dead-lettered for shop, then shop is disabled, evt-2 is enqueued, and at ts 50 ["enable", 50, "shop", true] is called. evt-1 and evt-2 are both due at 50; process at 50 attempts evt-1 (again, as attempt #1) and, since it dies again with its 404, evt-2 right after it in the same call.
Notes
config.py,transport.py,registry.pyandmodels.pyare known-good. The Part 1 bugs are in the retry logic.- You may use the AI assistant freely. Later parts must not break earlier ones: hidden tests re-check earlier behaviour, including exact attempt records and
retry attimes. - Parts 1 and 2 are the baseline; finishing all three is a strong result.
Example 1
- Input:
- [["register",0,"shop","https://shop.example/hooks"],["enqueue",0,"shop","evt-1",[503,503,200]],["process",0],["process",30],["process",89],["process",90],["status",90,"evt-1"]]
- Output:
- [true,"queued",["evt-1 #1 503 retry at 30"],["evt-1 #2 503 retry at 90"],[],["evt-1 #3 200 delivered"],{"state":"delivered","attempts":3,"next_attempt_at":null}]
- Explanation
- The first retry waits 30 seconds and the second 60. Nothing is due at 89; at 90 the third scripted outcome (200) delivers the event.
Example 2
- Input:
- [register shop, enqueue evt-1 [500,200] at 0, enqueue evt-2 [200] at 0, ["process",0], ["process",29], ["process",30]]
- Output:
- [true,"queued","queued",["evt-1 #1 500 retry at 30"],[],["evt-1 #2 200 delivered","evt-2 #1 200 delivered"]]
- Explanation
- evt-2 is due at time 0 but must wait for evt-1, which is awaiting a retry. When evt-1 is delivered at 30, evt-2 goes out in the same process call.
Example 3
- Input:
- [register shop, enqueue evt-1 [404] at 0, process 0, disable shop at 10, enqueue evt-2 [200] at 20, process 30, enable shop at 50 with replay_dead true, process 50]
- Output:
- [true,"queued",["evt-1 #1 404 dead"],true,"queued",[],true,["evt-1 #1 404 dead","evt-2 #1 200 delivered"]]
- Explanation
- Nothing runs while shop is disabled. Enabling with replay revives evt-1 (attempts reset), which is attempted before evt-2; it dies again on its 404 and evt-2 follows in the same call.
Guidelines
- Python 3 standard library only
- Time is simulated: timestamps are integer seconds and never decrease
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- An event gets at most 6 attempts; waits between attempts are 30, 60, 120, 240, then 300 seconds
- Delivery outcomes are scripted per event, so every run is deterministic