DEV Community

Emma
Emma

Posted on Originally published at supercrontab.com

I wanted a webhook receiver that could be slow and flaky on purpose, so I made one

There are plenty of "paste this URL and see what arrives" webhook testers, and they're fine for the first half of the job: is my service sending the right headers and body? What none of them did for me was the second half: what does my service do when the receiver is having a bad day? Times out. Returns 500. Works four times out of five.

That second half is where the expensive bugs live. Duplicate orders because a retry fired after a slow 200. Alerts that never went off because nobody tested the 500 path. So I built a receiver that can misbehave on request.

The boring half: see what you sent

You create an endpoint (takes about a minute: https://supercrontab.com/webhook-tester), get a URL like https://yourslug.supercrontab.com/, point your service at it. Every request shows up in a log with the method, path, headers, body (up to 16 KB), the caller's IP and the status you answered with. Authorization, Cookie and API key headers are redacted in the log, on purpose, because I didn't want to run a service that stores other people's tokens.

Sub-paths and trailing slashes work, so /orders/42/ reaches the same endpoint and you see the path in the log.

The useful half: make it misbehave

Same form, a few more fields:

  • status code, so 202 for "accepted" or 500 when you want to see your alerting fire
  • delay in milliseconds, to find out whether your sender times out and what it does after
  • error rate, so one call in five fails, which is what a flaky partner actually looks like
  • auth: Basic, Bearer, an API key header, or an HMAC-SHA256 signature over the body

My default setup is "202, 3 seconds, 10% errors". If your sender gets through a day of that with correct retries and no duplicated side effects, you're in decent shape.

The HMAC one caught a real bug for me: I was signing the pretty-printed JSON and sending the compact one. The endpoint answered 401 and I stared at it for ten minutes before I understood why.

The third mode: a scripted fault sequence

A random error rate tells you whether your sender retries at all. It doesn't reproduce one specific bug on demand, and you can't put "10% of the time" in CI. A reader of this post asked for deterministic fault sequences keyed by an idempotency key, and that turned out to be the right shape, so it's in.

You give the endpoint a list of steps. Each Idempotency-Key (or any header you name) walks the steps in order: call one gets step one, call two gets step two. Three actions:

  • respond: a status and body, optionally after a delay
  • drop: log the request, then cut the connection before the body is delivered
  • timeout: hold for up to 30 seconds, then cut

The one I use most reproduces the classic duplicate side-effect bug: the first delivery is logged and the connection is cut, the retry with the same key gets 200.

{ "steps": [ { "action": "drop" }, { "action": "respond", "status": 200 } ] }
Enter fullscreen mode Exit fullscreen mode

After the last step the endpoint repeats it, so a correct sender stays green; a new key starts again from step one. There's a "Reset sequence" button and a DELETE /v1/endpoints/{id}/sequence call for the start of a CI job.

One honest limit: the endpoints run on Cloudflare Workers, and there I can cut the connection mid-body but not before the status line. So drop looks like a truncated response (curl exit 18, Python IncompleteRead, Go body read error) rather than a reset. For most senders that's the same failure path.

Closing the loop

The same account has a cron scheduler, so you can schedule a job to call your endpoint every 15 minutes with a Bearer token and watch the job's run history and the endpoint's log agree with each other. I leave one running as a canary.

Why it needs an account

Anonymous receivers get used as drop boxes for stuff nobody should be storing. Tying every receiver to an account keeps the logs private, keeps the service clean, and lets you delete everything when you're done. The free plan has 3 endpoints, 30 requests a minute, 500 a day, and doesn't expire.

If you want to try only the response side first, without an account, there are 112 static demo endpoints (8 formats x 14 status codes) at https://supercrontab.com/mock, for example https://demo-json-503.supercrontab.com/.

Two things I still want: replaying a logged request to another URL, and exporting a log as a HAR. If you'd use either, tell me which first.

Top comments (3)

Collapse
 
raju_dandigam profile image
Raju Dandigam

@emma_teelylabs, controllable delay and failure rates cover the expensive half of webhook testing that capture-only tools miss. Before HAR replay, I’d be especially interested in deterministic fault sequences keyed by an idempotency key: for example, “commit the first request, close the connection before the response, then return 200 on retry.” That reproduces duplicate-side-effect bugs more reliably than a random 10% failure rate and makes the case suitable for regression tests. Would you expose scripted per-endpoint sequences as a third mode beside fixed and probabilistic responses?

Collapse
 
emma_teelylabs profile image
Emma

Thanks Raju, that's a sharper framing than my "10% errors" dial. A random error rate tells you whether the sender retries at all; it doesn't reproduce one specific bug on demand, and you can't put it in CI.

Yes, I'd like to add it as a third mode. Here's the shape I have in mind, and I'd love your input before I build it:

  • A sequence is a list of steps on the endpoint: respond (status, body, optional delay), drop (log the request, then cut the connection before the body is delivered), timeout (hold for N seconds, then drop).
  • Steps advance per idempotency key. Default key is the Idempotency-Key header; you can name another header. Requests without a key share one counter for the endpoint.
  • Your example becomes [{"action":"drop"},{"action":"respond","status":200}]: the first delivery is written to the log and the connection is cut, the retry with the same key gets 200. A third call with the same key repeats the last step, so a correct sender stays green; a new key starts again from step 1.
  • Counters can be reset from the dashboard or the API, so the same scenario runs clean on every CI job.

One honest constraint: the endpoints run at the edge, and there I can cut the connection mid-body but not before the status line. So drop looks like a truncated response (curl 18, Python IncompleteRead, Go body read error) rather than a reset. For most webhook senders that's the same failure path, but if you have a case where the difference matters, tell me.

And one question for you: after the last step, repeat the last step (my default) or loop back to step 1? Loop is handy for "every other delivery fails", but then the end state depends on how many retries the sender makes, which is the non-determinism we're trying to get rid of.

I'll reply here when it's live.

Collapse
 
emma_teelylabs profile image
Emma

It's in. There's now a third mode next to fixed and probabilistic responses: a sequence on the endpoint, a list of steps that each Idempotency-Key (or any header you name) walks in order. Actions are respond (status, body, delay), drop (log the request, then cut the connection) and timeout (hold up to 30 s, then cut).

Your example is exactly {"steps":[{"action":"drop"},{"action":"respond","status":200}]}: the first delivery is logged and the connection is cut, the retry with the same key gets 200, and further calls with that key keep getting 200 so a correct sender stays green. A new key starts from step 1. There's a "Reset sequence" button and DELETE /v1/endpoints/{id}/sequence for the start of a CI job. After the last step it repeats by default, or set "on_end": "loop".

One honest limit: the endpoints run on Cloudflare Workers, and there I can cut the connection mid-body but not before the status line. So drop shows up as a truncated response (curl exit 18, Python IncompleteRead, Go body read error) rather than a reset. Same failure path for most senders, but if you have a case where the difference matters, I'd like to hear it.

Details: supercrontab.com/docs#sequence