DEV Community

Cover image for How to Test Retry Logic Without a Backend
ToolboxMApp
ToolboxMApp

Posted on Originally published at scenariomock.com

How to Test Retry Logic Without a Backend

Originally published on the ScenarioMock blog.

You've written the retry logic. Exponential backoff, three attempts, then give up and show the user an error. It looks right.

How do you know it works?

Against a real staging backend, you'd have to break the thing on purpose. Against a mock, the endpoint cheerfully returns 200 every single time, and the retry branch never executes. The code ships untested, and the first time it runs for real is in production, during an outage.

Why static mocks can't test retry logic

A static mock maps one request shape to one response. Send the same request twice, get the same answer twice. That is exactly what makes it useless here.

Retry logic isn't a property of a single request. It's a property of a sequence of them. The behaviour you care about — does it back off, does it stop at the limit, does it recover cleanly when the endpoint comes back — only exists across multiple calls.

You can, of course, hard-code the fixture to return 503. Now your retry loop runs, exhausts its attempts, and surfaces an error. That tests the failure path. What it doesn't test is the recovery path, and that's where the interesting bugs live: state that never gets cleared, a loading spinner that stays up after a successful retry, a duplicate request fired because the first attempt wasn't properly cancelled.

To catch those, the endpoint has to fail and then succeed.

A Sequence scenario, step by step

A Sequence scenario in ScenarioMock keys off one thing: how many times it has been evaluated. You give it an ordered list of steps, each with a match_count and a response. The first step whose count matches wins.

match_count takes an exact number, or one of >=N, <=N, >N, <N.

So "fail twice, then succeed forever" is two steps:

Step match_count Response
1 <=2 503 Service Unavailable
2 >=3 200 Payment Success

That's the entire configuration. No code, no conditional branches, no fixture juggling.

Each response can also carry a delay_ms, which is what makes this useful for timeout testing too — a step that waits three seconds before returning 504 will exercise your client's timeout handling in a way a fast error response never will.

Try it right now

There's a public demo project you can call without signing up. Mock endpoints don't require authentication.

HOST=https://demo-mock--ecommerce-api.scenariomock.com

curl -s -X POST $HOST/api/payment \
  -H "Content-Type: application/json" \
  -d '{"amount":9800,"currency":"JPY","card":"4242424242424242"}'
Enter fullscreen mode Exit fullscreen mode

The first two calls return 503:

{"error":"Service temporarily unavailable","retryAfter":1,"requestId":"req_f3a2b91c"}
Enter fullscreen mode Exit fullscreen mode

The third returns 200:

{"status":"success","transactionId":"txn_8f3a2b91","amount":9800,"currency":"JPY","paidAt":"2026-06-21T10:00:00Z"}
Enter fullscreen mode Exit fullscreen mode

Point your retry client at that URL instead of curl and you have a real test of the failure-then-recovery path.

One caveat about this particular demo. Its counter is set to global scope and the counter clears 300 seconds after the first request, so you're sharing it with everyone else reading this page. If your first call comes back 200, someone else already used up the two failing calls shortly before you did — wait a few minutes and start again.

In your own project you'd use per_ip scope instead, which gives each caller an independent counter. That's the setting you want for anything other than a public demo.

There's a second Sequence endpoint on the same demo that models a flaky third-party API — first call times out after a three-second delay, second returns 500, third onward succeeds:

curl -s -X POST $HOST/api/shipping/quote \
  -H "Content-Type: application/json" \
  -d '{"zip":"100-0001","weight_kg":2.5}'
Enter fullscreen mode Exit fullscreen mode

Building the same thing in your own project

Four steps:

  1. Create a project. The slug becomes part of your mock's subdomain: https://{your-domain}--{slug}.scenariomock.com.
  2. Create an endpoint — method and path, for example POST /api/payment.
  3. Add a Sequence scenario. Define two steps: <=2 returning your error response, >=3 returning success.
  4. Set the scope to per_ip so concurrent testers don't share a counter.

To run the sequence again, reset the counter from the endpoint screen. You can also set reset_after_seconds so it clears on its own.

Two things that will trip you up

A request that matches no scenario returns 200, not an error.

If a request matches an endpoint but no scenario produces a response, you get:

{"message":"No scenario matched"}
Enter fullscreen mode Exit fullscreen mode

with status 200. This surprises people who expect a 404 or a 500.

The usual cause is steps that don't cover every count. If you define match_count: 1 and match_count: 2 and nothing else, the third request falls through every step, and you get the message above instead of the success response you were expecting. Using >=N on the final step avoids this.

reset_after_seconds is a window, not an idle timeout.

The timer starts on the first request and is not extended by subsequent ones. Setting it to 300 means the counter clears five minutes after the sequence began, regardless of how many calls happened in between. If you're expecting "clears after five minutes of inactivity", you'll get different behaviour than you planned.

Worth knowing as well: counters are tracked per scenario, not per endpoint. Two Sequence scenarios on the same endpoint each count their own evaluations independently.


The demo above is part of ScenarioMock, a hosted mock API server I built.
Free tier is enough to try this out — https://scenariomock.com

Curious how other people handle this. Are you testing retry paths at all,
or is that branch just shipping untested?

Top comments (0)