On this page
A service that answers 3 calls a second gets 20 calls in the same second, and the other 17 hear “too many requests”. In a toy test, 20 clients (programs that call a service) that retry the moment they are refused make 57 refused calls, and 2 of them give up after 6 tries. Give each client a wait that grows and has a random part, and the same crowd gets 25 refused calls, and nobody gives up. These numbers come from the code in this lesson and describe the toy, not a real service.
In one minute
A 429 “Too Many Requests” error means the service caps how fast you may call it, so wait before you try again, wait longer each time, and add a random part to each wait. (429 is a status code: a number in every reply that says how the call went.) Picture a crowd redialling a busy phone line. If everyone redials the moment the line frees up, it jams again. If each person waits a different, growing time, the calls spread out and get through. A program does the same: it doubles its wait after each refusal (backoff) and picks a random time up to that wait (jitter), so a crowd of programs does not come back together. If the service says how long to wait, wait at least that long. If it says your quota or spend cap is used up, a few seconds of waiting will not help.
Explore the numbers
Open the widget. A tick is one step of the toy’s clock; read it as one second. It starts with 20 clients under Retry at once, and the tall bars are refused calls. Pick Backoff: the bars spread out, but the refused total stays 57. Pick Backoff with jitter and it falls. Press Another random run to check it was not luck. More settings changes the crowd size, the calls answered per tick and the tries before a client gives up.
How it works
Why a service says no
A service has limited capacity, so it sets a rate limit (a cap on how often one customer may call; lesson 9 shows one). OpenAI’s guide (read 2026-10-19) says limits guard against abuse, share access fairly and manage load. A call over the cap gets status code 429, defined in RFC 6585 (a public internet standard). The reply may also carry a Retry-After header (an extra labelled line in the reply) that says when to try again, defined in RFC 9110.
A toy to test retry rules
The code runs a toy service that answers 3 calls in each tick and refuses the rest with 429. All 20 clients call at tick 0. A refused client waits and calls again, and gives up after 6 calls. A seed (a number that fixes the random choices, so a run can be repeated) is set to 1. Four rules set the wait:
- Retry at once: wait 1 tick.
- Fixed wait: wait 4 ticks every time.
- Backoff (short for exponential backoff): wait 4 ticks after the first refusal, then 8, then 16, the longest wait.
- Backoff with jitter: wait a random whole number of ticks, from 1 up to the backoff wait.
Retrying at once makes a wave
The 17 refused clients all come back at tick 1. Then 14 are refused, then 11, 8, 5 and 2: 57 refused calls in all. Two clients use up their 6 calls and give up. The run ends at tick 5 only because it dropped them.
Waiting longer is not enough
Fixed wait and backoff spread the calls over more ticks, but every client waits the same time as the others, so they all return together. Each still has 57 refused calls and loses 2 clients, and backoff runs to tick 60. The service sits idle for 55 of those 61 ticks.
Why a random wait helps
Jitter gives each client its own wait, anywhere from 1 tick up to the backoff time, so the crowd breaks up. After the 20 calls at tick 0, no tick has more than 7. The run has 25 refused calls, nobody gives up, and the last call is at tick 14. The cost is time: retry at once ends at tick 5, but only by dropping 2 clients.
Does it hold on other runs?
One run could be lucky. The three rules with no random part give the same run for every seed. For jitter, the code ran 1,000 seeds. Refused calls ranged from 22 to 29 (the middle run had 24) against 57, and no client gave up. Jitter always ended after retry at once, and before fixed wait in 963 of the 1,000 runs.
What this means when you build
- Read the reply first. If a temporary rate-limit 429 has a Retry-After header, wait at least that long. OpenAI’s guide (read 2026-10-19) calls it a minimum and suggests a small random extra. With no header, use backoff with jitter.
- Limit your own retries. Cap the number of tries and the longest wait, then show an error. Endless retries load a service that is already busy.
- Retry only errors that can pass. A temporary 429 and an “overloaded” error (the service says it is busy) clear with time. A 400 means the request itself is wrong, so change it. A 429 whose message says your quota, billing limit or spend cap is used up does not clear within a retry loop. It ends at a reset time or when the account changes, so fix the account, raise the limit or tell the user. OpenAI says not to retry quota or billing errors, and Anthropic says its spend-cap 429 has no retry-after header and fails until access resumes (both read 2026-10-19).
- Do not stack retry loops. A provider’s SDK (a code library it supplies) may retry for you. OpenAI says to turn that off, or count it, if you also retry yourself, or the loops multiply the calls.
- Do not resend at once. OpenAI says refused calls count toward your per-minute limit, so a tight loop keeps you refused.
Optionalabout 12 min
Go deeper
Optional, for readers who write Python. The code is in the code/ folder: sim.py is the toy, test_sim.py and test_widget.py are its tests, and python3 show_numbers.py prints every number above.
The four rules as formulas
Retry at once always waits 1 tick. Fixed wait always waits 4. Write L(k) = min(16, 4 × 2^(k − 1)) for the backoff wait after the kth refusal (k = 1 for the first). The constants are BASE = 4 and CAP = 16 in sim.py. Backoff waits L(k) ticks, and jitter waits any whole number of ticks from 1 up to L(k). The table gives both for each k.
| k | Backoff | Jitter |
|---|---|---|
| 1 | 4 | 1 to 4 |
| 2 | 8 | 1 to 8 |
| 3 | 16 | 1 to 16 |
| 4 | 16 | 1 to 16 |
| 5 | 16 | 1 to 16 |
Jitter draws 1 + int(u × L), where u is a random number from 0 up to but not including 1. Every whole number from 1 to L is equally likely, so the average wait is (L + 1) / 2. The AWS Architecture Blog post “Exponential Backoff And Jitter” (Marc Brooker, 4 March 2015, read 2026-10-19) names a rule Full Jitter: a random wait from 0 up to the current backoff. The toy’s rule is a small variant of it: it starts at 1 and not 0, because its clock moves forward one whole tick at a time, so a wait must be at least 1.
Why a crowd stays together
A refusal does not tell clients apart. Two clients refused in the same tick under a rule with no random part come back in the same tick, so a crowd that starts together stays together. This pile-up is often called a thundering herd. Each wave is smaller by what the service answered, 3 per tick, so the refused calls are 17 + 14 + 11 + 8 + 5 + 2 = 57. With these three rules, that sum depends on the crowd size, the limit and the number of tries, and not on how long the clients wait. Waiting changes when the waves arrive, not how big they are. Only a difference between clients, such as a random wait, splits the crowd.
The AWS post’s own test (read 2026-10-19) had 100 clients contending to write to a database, not to a rate limit. It found that exponential backoff without jitter helps only a little because clusters of calls remain, and that adding jitter cut the number of calls by more than half and shortened the time to finish. The Amazon Builders’ Library page on retries (read 2026-10-19) adds that retries can raise the load on a service that is already overloaded, and that capped backoff alone leaves every client retrying at the capped rate.
Worked example by hand
Take 5 clients, a service that answers 2 calls per tick, and 4 tries each. Under retry at once:
| Tick | What happens | Refused |
|---|---|---|
| 0 | Clients 0 to 4 call. Clients 0 and 1 are answered, and 2, 3 and 4 are refused. | 3 |
| 1 | Clients 2, 3 and 4 call. Clients 2 and 3 are answered, and 4 is refused. | 1 |
| 2 | Client 4 calls and is answered. | 0 |
That is 3 + 1 = 4 refused calls, and the last call is at tick 2. Under backoff, the first wait is 4 ticks and the second is 8:
| Tick | What happens | Refused |
|---|---|---|
| 0 | Clients 0 to 4 call. Clients 0 and 1 are answered, and 2, 3 and 4 are refused and call again at tick 4. | 3 |
| 4 | Clients 2, 3 and 4 call. Clients 2 and 3 are answered, and 4 is refused and calls again at tick 12. | 1 |
| 12 | Client 4 calls and is answered. | 0 |
The same 4 refused calls, and the last call is at tick 12 instead of 2. Under jitter with seed 1, the first three random numbers are 0.627074, 0.002736 and 0.527447. Clients 2, 3 and 4 are refused at tick 0, in that order, and L = 4, so their waits are 1 + int(0.627074 × 4) = 3, 1 + int(0.002736 × 4) = 1 and 1 + int(0.527447 × 4) = 3.
| Tick | What happens | Refused |
|---|---|---|
| 0 | Clients 0 to 4 call. Clients 0 and 1 are answered, and 2, 3 and 4 are refused and call again at ticks 3, 1 and 3. | 3 |
| 1 | Client 3 calls and is answered. | 0 |
| 3 | Clients 2 and 4 call and are answered. | 0 |
Only 3 refused calls, and the last call is at tick 3.
What jitter costs
The average jitter wait after the kth refusal is (L(k) + 1) / 2, which is 2.5, 4.5, 8.5, 8.5 and 8.5 ticks, against 4, 8, 16, 16 and 16 for plain backoff. So jitter is not a longer wait: on average it is about half as long, and what it adds is spread. show_numbers.py draws 20,000 waits for each refusal and prints averages of 2.50, 4.51, 8.48, 8.49 and 8.52. That is why jitter ends at tick 14 in the run above and plain backoff at tick 60. Fixed wait ended at tick 20, and jitter did not finish before it in 37 of the 1,000 seeds. Jitter’s slowest run ended at tick 25.
How a server counts
The toy’s limiter is the simplest one: count the calls in each tick and refuse those above the limit (a fixed window). A fixed window has an edge: a client can send a full allowance at the end of one window and another at the start of the next. The token bucket from lesson 9 gains tickets at a steady speed and spends one per call, so it allows a short burst and then a steady rate. Anthropic’s documentation (read 2026-10-19) says it uses the token bucket algorithm, and that a limit per minute may be enforced over shorter spans, so a short burst can get a 429 even when the minute’s total is under the limit. Limits for your own users come in the next lesson.
The code
wait_after holds the four rules. It takes the rule, the number of refusals so far, and draw, a function that returns the next random number.
def wait_after(rule, refusals, draw):
"""Ticks to wait before the next call, after this many refusals so far (1 after the first)."""
if rule == "once":
return 1
if rule == "fixed":
return FIXED
longest = min(CAP, BASE * 2 ** (refusals - 1))
if rule == "backoff":
return longest
return 1 + int(draw() * longest) # jitter: any whole number of ticks from 1 up to the longest wait
Only jitter calls draw, so the other three rules never use a random number and give the same run for every seed. simulate runs the crowd. due[c] is the tick of client c’s next call, and None once it is served or has given up.
def simulate(rule, seed=SEED, clients=CLIENTS, limit=LIMIT, tries=TRIES):
draw = random_numbers(seed).__next__
due = [0] * clients # the tick of each client's next call; None once it is served or has given up
refusals = [0] * clients
served, refused, gave_up, tick = [], [], 0, 0
while any(d is not None for d in due):
calling = [c for c in range(clients) if due[c] == tick]
for c in calling[:limit]:
due[c] = None
for c in calling[limit:]:
refusals[c] += 1
if refusals[c] == tries:
due[c] = None
gave_up += 1
else:
due[c] = tick + wait_after(rule, refusals[c], draw)
served.append(min(len(calling), limit))
refused.append(max(len(calling) - limit, 0))
tick += 1
return Run(rule, tuple(served), tuple(refused), gave_up)
Each tick, calling lists the clients due now. The first limit are answered. Each of the rest adds 1 to its refusals, gives up if that equals tries, and otherwise waits wait_after(...) ticks. A tick with no calls still adds a 0 to both lists, which is how the idle ticks show up. Clients go in number order, so the random numbers are drawn in a fixed order and one seed always gives one run. random_numbers is a 32-bit generator called mulberry32. widget.html holds the same generator in JavaScript, and the tests run both and compare.
Proof it works
cd lessons/too-many-requests/code && python3 -m pytest -q -x
The tests need no network. Each check below has a test that fails when the check is removed.
- Backoff doubles, then stops at the cap:
test_backoff_doubles_until_the_cap - Jitter waits are whole ticks from 1 to the backoff wait:
test_jitter_waits_are_whole_ticks_from_one_up_to_the_backoff_wait_and_reach_both_ends - The first three rules ignore the random numbers:
test_the_three_rules_with_no_random_part_give_the_same_run_for_every_seed - The service answers at most its limit:
test_every_call_is_counted_once_and_the_server_never_answers_more_than_its_limit - A client gives up after its last try, not before:
test_a_client_gives_up_after_its_last_try_and_not_before - The lesson’s numbers for one run:
test_the_lessons_one_run_for_the_rules_with_no_random_partandtest_the_lessons_one_jitter_run - Jitter wins on every seed:
test_waiting_longer_does_not_cut_refused_calls_but_jitter_does_on_every_seed - The hand example:
test_a_small_crowd_by_hand_matches_the_lessons_table - Same random numbers in Python and JavaScript:
test_the_generator_gives_the_same_numbers_everywhereandtest_widget_rules_match_the_python_sim
The test of the seeds checks the claim that matters most. For every one of 1,000 seeds, jitter serves every client, its refused calls are fewer than the other rules’, and its biggest retry wave is smaller. Another test fixes the honest cost: retry at once finishes first only because it drops 2 clients. Other tests check that the widget’s data block matches the code, that its script finds every element it looks up, that it asks for nothing online, and that every Python block in this lesson is a verbatim excerpt of sim.py.
What the toy leaves out
- Refused calls are free. In the toy a refusal costs a client only one of its tries. OpenAI says refused calls count toward the per-minute limit (read 2026-10-19), so a real service can be harsher on a client that resends at once.
- One burst. All clients start at tick 0. Real traffic arrives all the time, from clients that do not know about each other.
- Ticks are not seconds. The numbers show the shape, not real delays. A different crowd, limit, cap or number of tries gives different numbers, and the claim is only that the three fixed rules stay in step and jitter does not.
- No Retry-After. The toy never tells a client how long to wait. A real Retry-After is a number of seconds or a date (RFC 9110), and a client that is told should follow it, as OpenAI and Anthropic both say.
- Ties go to the lowest number. The service answers the lowest client numbers first, and real services do not.
- Sudden jumps. OpenAI and Anthropic (both read 2026-10-19) say a 429 can also come when your rate rises sharply, even below the stated limits. Both advise raising it gradually.
Where this is used in practice
OpenAI’s rate-limit guide (read 2026-10-19) says its official SDKs retry eligible 429 and 503 errors on their own. For your own client it says to follow Retry-After if it is valid, else use exponential backoff with jitter, and to limit both the number of attempts and the total time spent retrying. It says Retry-After may be present on 429s caused by a temporary rate limit, that quota and billing errors cannot be resolved by retrying, and that other errors can share a status code, so the error body should be inspected before choosing a recovery action. Anthropic’s rate-limit page (read 2026-10-19) says to wait the number of seconds in the retry-after header, and that at a spend cap the API returns 429 with no retry-after header and retrying fails until access resumes. Its errors page lists a 529 overloaded_error. Google’s Gemini documentation (read 2026-10-19) measures limits in requests per minute, tokens per minute and requests per day, applied per project, and says going over any one of them triggers an error. It says requests-per-day quotas reset at midnight Pacific time, so a daily quota that is used up stays used up until then. The AWS blog post above (March 2015) says most AWS SDKs support exponential backoff and jitter, as of its May 2023 update.
Skills this lesson builds
- Read a 429 reply and its Retry-After header, tell a temporary rate limit from a used-up quota or spend cap, and decide whether an error is worth retrying.
- Write a retry loop with a cap on tries, a longest wait, backoff and jitter.
- Test a retry loop without waiting, by passing in the random source and counting ticks and not seconds.
- Measure retry rules against a limited server over many seeds, and report refused calls, time to finish and clients that gave up.
- Explain why clients that start together stay together, and what jitter changes.
Portfolio project: build a fake rate-limited server and three clients (no retry, fixed wait, backoff with jitter). Publish a seeded test that runs 1,000 seeds, a table of refused calls, finish time and clients that gave up for each client, and a note on how your client treats Retry-After.
Check yourself
-
A service replies 429 and the reply has a Retry-After header that says 30 seconds. What should your program do, and what if there is no header?
Answer
Wait at least 30 seconds, then try again. With no header, wait a time that grows after each refusal and has a random part (backoff with jitter), and stop after a set number of tries or a longest total wait. All of this is for a temporary rate limit. If the message says a quota, billing limit or spend cap is used up (usually with no Retry-After), do not retry: fix the account, raise the limit or tell the user.
-
In the toy, backoff made as many refused calls as retrying at once. Why did waiting longer not help?
Answer
Every client waits the same time, so the clients refused together come back together and are refused together again. Waiting changes when the waves arrive and not how big they are. A random part gives each client its own wait, which splits the crowd.
-
What are the shortest wait, the longest wait and the average wait that jitter can give after the third refusal?
Answer
L(3) = min(16, 4 × 2^2) = 16. The shortest wait is 1 tick and the longest is 16. The average is (16 + 1) / 2 = 8.5 ticks, about half the 16 of plain backoff.
-
Five clients call at tick 0 and retry at once, and the service answers 3 calls per tick. Which clients are refused, and when does the last call happen?
Answer
At tick 0, clients 0, 1 and 2 are answered and clients 3 and 4 are refused. They call again at tick 1 and are both answered, so there are 2 refused calls and the last call is at tick 1.
-
In the toy, retrying at once looks fastest. Why is that misleading, and what else does the toy leave out that could make it worse for a client in real life?
Answer
It ends at tick 5 only because 2 clients use up their 6 tries and give up, so they are never served; jitter serves everyone. Also, the toy’s refused calls cost a client nothing but a try, while OpenAI says refused calls count toward the per-minute limit, so resending at once can keep a real client refused.
Further reading
Web pages were read on 2026-10-19; providers change them.
Gentle
- Rate limits (OpenAI documentation)
- Rate limits (Anthropic documentation)
- Errors (Anthropic documentation)
- Rate limits (Google AI for Developers)
- 429 Too Many Requests (MDN)
- Retry-After (MDN)
Deep
- RFC 6585, section 4: 429 Too Many Requests (Nottingham and Fielding, 2012)
- RFC 9110, section 10.2.3: Retry-After (Fielding, Nottingham and Reschke, 2022)
- Exponential Backoff And Jitter (Marc Brooker, AWS Architecture Blog, 2015)
- Timeouts, retries, and backoff with jitter (Amazon Builders’ Library)
Optional
Code
Each file runs on its own. Download it, or read it here.
Show code
"""Keep the data inside ../widget.html equal to the data in sim.py.
python3 embed_widget.py rewrite the data block in widget.html
python3 embed_widget.py --check exit 1 if widget.html differs (test_widget.py runs the same check)
"""
import json
import re
import sys
from pathlib import Path
from sim import BASE, CAP, CLIENTS, FIXED, LIMIT, RULES, SEED, TRIES
WIDGET = Path(__file__).resolve().parent.parent / "widget.html"
CONFIG_BLOCK = re.compile(r'(<script type="application/json" id="rl-config">)(.*?)(</script>)', re.S)
def config():
return {"clients": CLIENTS, "limit": LIMIT, "tries": TRIES, "fixed": FIXED, "base": BASE, "cap": CAP, "seed": SEED,
"rules": [{"id": i, "name": name} for i, name in RULES]}
def embed(html):
"""html with the config block rewritten from sim.py."""
block = "{\n" + ",\n".join(f" {json.dumps(key)}: {json.dumps(value)}" for key, value in config().items()) + "\n}"
return CONFIG_BLOCK.sub(lambda m: m.group(1) + "\n" + block + "\n" + m.group(3), html, count=1)
if __name__ == "__main__":
current = WIDGET.read_text(encoding="utf-8")
updated = embed(current)
if "--check" in sys.argv:
sys.exit(0 if updated == current else 1)
WIDGET.write_text(updated, encoding="utf-8")
print("widget.html data block now matches sim.py")Show code
"""Print every number the lesson quotes. Run it from this folder: python3 show_numbers.py"""
import statistics
from sim import BASE, CAP, CLIENTS, FIXED, LIMIT, RULES, SEED, TRIES, random_numbers, simulate, wait_after
SEEDS = range(1, 1001) # how many random runs back the jitter figures
def row(name, run):
return (f" {name} | {run.finished} | {run.refused_calls} | {run.gave_up} | {run.busiest_retry_tick} | "
f"{sum(1 for s, r in zip(run.served, run.refused) if s + r == 0)}")
print(f"== One run: {CLIENTS} clients, the server answers {LIMIT} calls per tick, {TRIES} tries each, seed {SEED}")
print(" rule | last call at tick | refused calls | clients that gave up | busiest retry tick | ticks with no call at all")
for rule, name in RULES:
print(row(name, simulate(rule)))
print("\n== Calls per tick, seed", SEED, "(answered + refused)")
for rule, name in RULES:
run = simulate(rule)
print(f" {name}: " + " ".join(str(s + r) for s, r in zip(run.served, run.refused)))
print("\n== The waits, in ticks, after the 1st, 2nd, ... refusal")
print(" backoff: " + ", ".join(str(wait_after("backoff", k, None)) for k in range(1, TRIES)))
print(" jitter picks a whole number from 1 up to: " + ", ".join(str(min(CAP, BASE * 2 ** (k - 1))) for k in range(1, TRIES)))
print(f" fixed: {FIXED} every time; at once: 1 every time")
_gen = random_numbers(99)
print(" jitter average over 20,000 waits: " + ", ".join(
f"{sum(wait_after('jitter', k, _gen.__next__) for _ in range(20000)) / 20000:.2f}" for k in range(1, TRIES)))
print(f"\n== {len(SEEDS)} seeds, jitter: min / median / max")
runs = [simulate("jitter", seed) for seed in SEEDS]
for label, values in (("last call at tick", [r.finished for r in runs]), ("refused calls", [r.refused_calls for r in runs]),
("clients that gave up", [r.gave_up for r in runs]), ("busiest retry tick", [r.busiest_retry_tick for r in runs])):
print(f" {label}: {min(values)} / {statistics.median(values)} / {max(values)}")
once, fixed = simulate("once"), simulate("fixed")
print(f" runs where jitter finished after retry-at-once: {sum(r.finished > once.finished for r in runs)} of {len(runs)}")
print(f" runs where jitter finished before the fixed wait: {sum(r.finished < fixed.finished for r in runs)} of {len(runs)}")
print(" the three rules with no random part give the same run for every seed: "
f"{all(simulate(rule, seed) == simulate(rule) for rule in ('once', 'fixed', 'backoff') for seed in range(1, 51))}")
print("\n== The first five numbers from the generator, seed 1")
gen = random_numbers(1)
print(" " + ", ".join(f"{next(gen):.6f}" for _ in range(5)))
print("\n== A small crowd by hand: 5 clients, 2 calls per tick, 4 tries")
for rule in ("once", "backoff"):
run = simulate(rule, clients=5, limit=2, tries=4)
print(f" {rule}: served {run.served}, refused {run.refused}, gave up {run.gave_up}")Show code
"""A busy server and a crowd of clients that retry. Python standard library only.
This is a toy, not a model of any real provider. Time moves in whole ticks (think of a tick as one second).
The server answers at most LIMIT calls in a tick; every other call that tick gets "too many requests" (HTTP 429).
All clients make their first call at tick 0. A refused client waits, then calls again, until it is served or has
used up its tries. The four rules differ only in how long the wait is. The same seed always gives the same run.
"""
from dataclasses import dataclass
CLIENTS = 20 # clients that all call at tick 0
LIMIT = 3 # calls the server answers per tick
TRIES = 6 # a client gives up after this many calls
FIXED = 4 # the fixed-wait rule: ticks between calls
BASE = 4 # backoff: the wait after the first refusal, in ticks
CAP = 16 # backoff: the longest wait, in ticks
SEED = 1 # the seed of the run the lesson shows
RULES = (("once", "Retry at once"), ("fixed", "Fixed wait"), ("backoff", "Backoff"), ("jitter", "Backoff with jitter"))
MASK = 0xFFFFFFFF
def random_numbers(seed):
"""Endless numbers in [0, 1), the same on every machine (the mulberry32 generator; widget.html has the same code)."""
state = seed & MASK
while True:
state = (state + 0x6D2B79F5) & MASK
t = ((state ^ (state >> 15)) * (state | 1)) & MASK
t ^= (t + (((t ^ (t >> 7)) * (t | 61)) & MASK)) & MASK
yield ((t ^ (t >> 14)) & MASK) / 4294967296
def wait_after(rule, refusals, draw):
"""Ticks to wait before the next call, after this many refusals so far (1 after the first)."""
if rule == "once":
return 1
if rule == "fixed":
return FIXED
longest = min(CAP, BASE * 2 ** (refusals - 1))
if rule == "backoff":
return longest
return 1 + int(draw() * longest) # jitter: any whole number of ticks from 1 up to the longest wait
@dataclass(frozen=True)
class Run:
rule: str
served: tuple # per tick: calls the server answered
refused: tuple # per tick: calls that got "too many requests"
gave_up: int # clients that used up their tries without being served
@property
def finished(self):
"""The tick of the last call anyone made."""
return len(self.served) - 1
@property
def refused_calls(self):
return sum(self.refused)
@property
def busiest_retry_tick(self):
"""The most calls in one tick after the first tick: the biggest wave of retries."""
return max((s + r for s, r in zip(self.served[1:], self.refused[1:])), default=0)
def simulate(rule, seed=SEED, clients=CLIENTS, limit=LIMIT, tries=TRIES):
draw = random_numbers(seed).__next__
due = [0] * clients # the tick of each client's next call; None once it is served or has given up
refusals = [0] * clients
served, refused, gave_up, tick = [], [], 0, 0
while any(d is not None for d in due):
calling = [c for c in range(clients) if due[c] == tick]
for c in calling[:limit]:
due[c] = None
for c in calling[limit:]:
refusals[c] += 1
if refusals[c] == tries:
due[c] = None
gave_up += 1
else:
due[c] = tick + wait_after(rule, refusals[c], draw)
served.append(min(len(calling), limit))
refused.append(max(len(calling) - limit, 0))
tick += 1
return Run(rule, tuple(served), tuple(refused), gave_up)Show code
"""Tests for sim.py. The three rules with no random part give one fixed answer, so the lesson's numbers for them are exact.
Jitter is random, so its numbers are checked over many seeds, not one."""
import re
import statistics
import textwrap
from pathlib import Path
import pytest
from sim import BASE, CAP, CLIENTS, FIXED, LIMIT, RULES, SEED, TRIES, random_numbers, simulate, wait_after
SEEDS = range(1, 1001)
RULE_IDS = [rule for rule, _ in RULES]
NO_RANDOM = ("once", "fixed", "backoff")
def test_the_generator_gives_the_same_numbers_everywhere():
"""Guards the seed: the widget, the lesson's numbers and these tests all depend on this exact sequence."""
gen = random_numbers(1)
assert [round(next(gen), 6) for _ in range(5)] == [0.627074, 0.002736, 0.527447, 0.981051, 0.968378]
assert random_numbers(2 ** 32 + 1).__next__() == random_numbers(1).__next__() # the seed is a 32-bit number
def test_the_numbers_are_in_range_and_spread_out():
gen = random_numbers(7)
values = [next(gen) for _ in range(10000)]
assert min(values) >= 0 and max(values) < 1 and abs(statistics.mean(values) - 0.5) < 0.02
@pytest.mark.parametrize("refusals, expected", [(1, 4), (2, 8), (3, 16), (4, 16), (5, 16)])
def test_backoff_doubles_until_the_cap(refusals, expected):
assert wait_after("backoff", refusals, None) == expected
assert (BASE, CAP) == (4, 16) # the lesson quotes these
def test_once_and_fixed_ignore_how_many_refusals_came_before():
assert [wait_after("once", k, None) for k in range(1, 6)] == [1] * 5
assert [wait_after("fixed", k, None) for k in range(1, 6)] == [FIXED] * 5 == [4] * 5
def test_jitter_waits_are_whole_ticks_from_one_up_to_the_backoff_wait_and_reach_both_ends():
"""Guards an off-by-one: a wait of 0 would retry in the same tick, and a wait that never reaches the top is not full jitter."""
for refusals in range(1, TRIES):
gen = random_numbers(refusals)
longest = wait_after("backoff", refusals, None)
waits = [wait_after("jitter", refusals, gen.__next__) for _ in range(2000)]
assert min(waits) == 1 and max(waits) == longest and all(isinstance(w, int) for w in waits)
assert abs(statistics.mean(waits) - (longest + 1) / 2) < 0.3 # the lesson says the average wait is (longest + 1) / 2
def test_the_three_rules_with_no_random_part_give_the_same_run_for_every_seed():
"""The lesson says this, so only jitter needs many seeds."""
for rule in NO_RANDOM:
assert all(simulate(rule, seed) == simulate(rule) for seed in SEEDS)
def test_the_lessons_one_run_for_the_rules_with_no_random_part():
runs = {rule: simulate(rule) for rule in NO_RANDOM}
assert {rule: (run.finished, run.refused_calls, run.gave_up, run.busiest_retry_tick) for rule, run in runs.items()} == {
"once": (5, 57, 2, 17), "fixed": (20, 57, 2, 17), "backoff": (60, 57, 2, 17)}
assert [s + r for s, r in zip(runs["once"].served, runs["once"].refused)] == [20, 17, 14, 11, 8, 5]
idle = lambda run: sum(1 for s, r in zip(run.served, run.refused) if s + r == 0)
assert (idle(runs["once"]), idle(runs["fixed"]), idle(runs["backoff"])) == (0, 15, 55) # backoff leaves the server idle on 55 of 61 ticks
def test_the_lessons_one_jitter_run():
run = simulate("jitter", SEED)
assert (run.finished, run.refused_calls, run.gave_up, run.busiest_retry_tick) == (14, 25, 0, 7)
assert [s + r for s, r in zip(run.served, run.refused)] == [20, 4, 7, 4, 5, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1]
def test_waiting_longer_does_not_cut_refused_calls_but_jitter_does_on_every_seed():
"""The claim behind the whole lesson, checked on 1,000 seeds so it is not one lucky run."""
waste = {rule: {simulate(rule, seed).refused_calls for seed in SEEDS} for rule in RULE_IDS}
assert waste["once"] == waste["fixed"] == waste["backoff"] == {57}
assert max(waste["jitter"]) < 57 and statistics.median(waste["jitter"]) < 57 * 0.5
def test_jitter_serves_every_client_and_shrinks_the_biggest_retry_wave_on_every_seed():
runs = [simulate("jitter", seed) for seed in SEEDS]
assert all(run.gave_up == 0 for run in runs)
assert max(run.busiest_retry_tick for run in runs) < simulate("once").busiest_retry_tick == 17
def test_retrying_at_once_finishes_first_only_because_it_gives_up_on_two_clients():
"""The honest cost of jitter, stated in the lesson: it never finishes before retry-at-once, in this toy."""
once = simulate("once")
assert once.gave_up == 2 and sum(once.served) == CLIENTS - 2
assert all(simulate("jitter", seed).finished > once.finished for seed in SEEDS)
def test_jitter_usually_finishes_before_the_fixed_wait_and_always_before_plain_backoff():
runs = [simulate("jitter", seed) for seed in SEEDS]
assert statistics.median(run.finished for run in runs) < simulate("fixed").finished
assert max(run.finished for run in runs) < simulate("backoff").finished
@pytest.mark.parametrize("rule", RULE_IDS)
def test_every_call_is_counted_once_and_the_server_never_answers_more_than_its_limit(rule):
"""Guards the bookkeeping: served + gave up is every client, no tick answers over the limit, nobody calls over their tries."""
for seed in range(1, 31):
run = simulate(rule, seed)
assert sum(run.served) + run.gave_up == CLIENTS
assert max(run.served) <= LIMIT and len(run.served) == len(run.refused)
assert sum(run.served) + run.refused_calls <= CLIENTS * TRIES
assert all(s == LIMIT for s, r in zip(run.served, run.refused) if r > 0) # a refusal only happens when the server is full
@pytest.mark.parametrize("rule", RULE_IDS)
def test_no_refusals_when_the_server_can_answer_everyone_at_once(rule):
run = simulate(rule, clients=10, limit=10)
assert (run.finished, run.refused_calls, run.gave_up, run.served) == (0, 0, 0, (10,))
def test_a_small_crowd_by_hand_matches_the_lessons_table():
once = simulate("once", clients=5, limit=2, tries=4)
assert (once.served, once.refused, once.gave_up) == ((2, 2, 1), (3, 1, 0), 0)
backoff = simulate("backoff", clients=5, limit=2, tries=4)
assert backoff.served == (2, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 1)
assert backoff.refused == (3, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0) and backoff.gave_up == 0
# seed 1: clients 2, 3 and 4 are refused in that order and draw 0.627074, 0.002736, 0.527447, so they wait 3, 1 and 3 ticks
jitter = simulate("jitter", 1, clients=5, limit=2, tries=4)
assert (jitter.served, jitter.refused, jitter.gave_up) == ((2, 1, 0, 2), (3, 0, 0, 0), 0)
def test_a_client_gives_up_after_its_last_try_and_not_before():
run = simulate("once", clients=4, limit=1, tries=2)
assert (run.served, run.refused, run.gave_up) == ((1, 1), (3, 2), 2)
def test_the_run_is_the_same_every_time_for_one_seed():
assert simulate("jitter", 5) == simulate("jitter", 5) != simulate("jitter", 6)
def test_the_module_uses_only_the_standard_library_and_no_network():
source = Path(__file__).with_name("sim.py").read_text()
assert set(re.findall(r"^(?:from|import) (\w+)", source, flags=re.M)) == {"dataclasses"}
def test_every_python_block_in_the_lesson_is_a_verbatim_excerpt_of_the_source():
"""The lesson quotes this file. If the code changes and the lesson does not, the reader sees code that was never tested."""
lesson = (Path(__file__).parent.parent / "lesson.md").read_text()
source = Path(__file__).with_name("sim.py").read_text().splitlines()
blocks = re.findall(r"^```python\n(.*?)^```", lesson, flags=re.S | re.M)
assert blocks, "the lesson has no python blocks to check"
for block in blocks:
lines = block.rstrip("\n").splitlines()
windows = (textwrap.dedent("\n".join(source[i:i + len(lines)])) for i in range(len(source) - len(lines) + 1))
assert textwrap.dedent("\n".join(lines)) in windows, f"not in sim.py:\n{block}"Show code
"""Guards for ../widget.html: its data block, what it may show, and that its rules give the same answers as sim.py."""
import json
import re
import subprocess
from embed_widget import WIDGET, config, embed
from sim import CLIENTS, LIMIT, RULES, SEED, TRIES, random_numbers, simulate
HTML = WIDGET.read_text(encoding="utf-8")
NAMES = dict(RULES)
# Reads the rules out of the widget between its marker comments, runs them, and compares with the Python answers.
PARITY = r"""
import fs from 'node:fs';
const [fixture, widget] = process.argv.slice(2);
const fx = JSON.parse(fs.readFileSync(fixture, 'utf8'));
const html = fs.readFileSync(widget, 'utf8');
const cfg = JSON.parse(html.match(/<script type="application\/json" id="rl-config">([\s\S]*?)<\/script>/)[1]);
const js = html.slice(html.indexOf("/* ---------- the toy's rules"), html.indexOf('/* ---------- the page'));
const api = new Function('cfg', "'use strict';" + js + '; return {randomNumbers, waitAfter, simulate, summary};')(cfg);
const bad = [];
fx.numbers.forEach((n) => {
const next = api.randomNumbers(n.seed);
n.values.forEach((v, i) => { if (next() !== v) bad.push(['number', n.seed, i]); });
});
fx.runs.forEach((r) => {
const got = api.simulate(r.rule, r.seed, r.clients, r.limit, r.tries);
if (JSON.stringify([got.served, got.refused, got.gaveUp]) !== JSON.stringify([r.served, r.refused, r.gaveUp])) bad.push(['run', r.rule, r.seed, r.clients, r.limit, r.tries]);
if (JSON.stringify(api.summary(got)) !== JSON.stringify(r.summary)) bad.push(['summary', r.rule, r.seed, r.clients, r.limit, r.tries]);
});
fx.waits.forEach((w) => { if (api.waitAfter(w.rule, w.refusals, () => w.draw) !== w.wait) bad.push(['wait', w.rule, w.refusals, w.draw]); });
console.log(JSON.stringify({ numbers: fx.numbers.length, runs: fx.runs.length, waits: fx.waits.length, bad: bad.slice(0, 5) }));
process.exit(bad.length ? 1 : 0);
"""
# Runs the page script (everything after the rules) against a small stand-in for the DOM, and reads back what a reader would see.
PAGE_TEST = r"""
import fs from 'node:fs';
const [widget, fixture] = process.argv.slice(2);
const html = fs.readFileSync(widget, 'utf8');
const states = JSON.parse(fs.readFileSync(fixture, 'utf8')).states;
const cfgText = html.match(/<script type="application\/json" id="rl-config">([\s\S]*?)<\/script>/)[1];
const code = html.match(/<script>([\s\S]*?)<\/script>/)[1];
function el(tag) {
const e = { tag, children: [], listeners: {}, className: '', checked: false, value: '', style: {}, attributes: {}, _text: '' };
e.addEventListener = (type, f) => (e.listeners[type] = e.listeners[type] || []).push(f);
e.appendChild = (c) => (e.children.push(c), c);
e.setAttribute = (k, v) => { e.attributes[k] = v; };
Object.defineProperty(e, 'textContent', { get: () => e._text, set: (v) => { e._text = String(v); e.children = []; } });
return e;
}
function open() {
const page = {};
['rl-config', 'rl-title', 'rl-cap', 'rl-live', 'rl-picks', 'rl-what', 'rl-chart', 'rl-end', 'rl-top', 'rl-big', 'rl-sub', 'rl-again', 'rl-note', 'rl-rows',
'rl-clients', 'rl-limit', 'rl-tries', 'rl-clients-v', 'rl-limit-v', 'rl-tries-v'].forEach((id) => { page[id] = el('x'); });
page['rl-config']._text = cfgText;
const doc = { getElementById: (id) => page[id], createElement: el, createTextNode: (text) => ({ text }), documentElement: { setAttribute() {} } };
new Function('document', 'window', 'parent', code)(doc, { addEventListener() {} }, {});
return page;
}
const fire = (e, type) => (e.listeners[type] || []).forEach((f) => f());
const radios = (page) => page['rl-picks'].children.map((label) => label.children[0]);
const labelText = (page, i) => page['rl-picks'].children[i].children[1].text;
const cells = (tr) => tr.children.map((c) => c._text);
const bad = [];
const want = (what, got, expected) => { if (JSON.stringify(got) !== JSON.stringify(expected)) bad.push([what, got, expected]); };
states.forEach((st) => {
const page = open();
['clients', 'limit', 'tries'].forEach((key) => { if (st[key] !== undefined) { page['rl-' + key].value = String(st[key]); fire(page['rl-' + key], 'input'); } });
const picks = radios(page);
picks.forEach((r, i) => { r.checked = i === st.pick; });
if (st.pick > 0) fire(picks[st.pick], 'change');
for (let i = 0; i < st.again; i++) fire(page['rl-again'], 'click');
want(st.name + ': title', page['rl-title']._text, st.title);
want(st.name + ': caption', page['rl-cap']._text, st.cap);
want(st.name + ': rule description', page['rl-what']._text, st.what);
want(st.name + ': headline', page['rl-big']._text, st.big);
want(st.name + ': second line', page['rl-sub']._text, st.sub);
want(st.name + ': note beside the button', page['rl-note']._text, st.note);
want(st.name + ': table', page['rl-rows'].children.map(cells), st.rows);
want(st.name + ': only the picked rule is marked', page['rl-rows'].children.map((tr) => tr.className), st.marks);
want(st.name + ': slider read-outs', [page['rl-clients-v']._text, page['rl-limit-v']._text, page['rl-tries-v']._text], st.sliders);
want(st.name + ': last tick label', page['rl-end']._text, 'tick ' + (st.columns - 1));
want(st.name + ': full-height label', page['rl-top']._text, 'full height = ' + st.sliders[0] + ' calls');
const cols = page['rl-chart'].children;
want(st.name + ': one column per tick, at least 12', cols.length, st.columns);
want(st.name + ': every bar is answered (green) under refused (orange), as the legend says', [...new Set(cols.map((c) => c.children.map((b) => b.className).join(' ')))], ['rl-ok rl-no']);
const heights = cols.map((c) => [parseFloat(c.children[0].style.height), parseFloat(c.children[1].style.height)]);
heights.forEach((h, t) => {
const e = st.heights[t] || [0, 0];
if (!(Math.abs(h[0] - e[0]) <= 0.05 && Math.abs(h[1] - e[1]) <= 0.05)) bad.push([st.name + ': bar heights at tick ' + t, h, e]);
});
const touched = st.pick > 0 || st.again > 0 || ['clients', 'limit', 'tries'].some((key) => st[key] !== undefined);
want(st.name + ': screen reader line', touched ? page['rl-live']._text === st.rule + ': ' + st.big + ' ' + st.sub : page['rl-live']._text === '', true);
});
const headers = [...html.match(/<thead>[\s\S]*?<\/thead>/)[0].matchAll(/<th scope="col">([^<]*)<\/th>/g)].map((m) => m[1]);
want('the results table headers, in the order of the cells the script writes', headers, ['Rule', 'Refused', 'Gave up', 'Done at', 'Retry peak']);
const legend = [...html.matchAll(/<i class="rl-sw (rl-\w+)"><\/i>([^<]*)<\/span>/g)].map((m) => [m[1], m[2]]);
want('the legend names each bar color', legend, [['rl-ok', 'answered'], ['rl-no', 'refused (429)']]);
const liveTag = html.match(/<p[^>]*id="rl-live"[^>]*>/)[0];
want('the live region is a polite status read whole', ['role="status"', 'aria-live="polite"', 'aria-atomic="true"'].map((a) => liveTag.includes(a)), [true, true, true]);
const start = radios(open());
want('the four radios share one group name', new Set(start.map((r) => r.name)).size === 1 && !!start[0].name, true);
want('the first rule is checked at the start', start.map((r) => r.checked), [true, false, false, false]);
want('the radio labels are the rule names', [0, 1, 2, 3].map((i) => labelText(open(), i)), JSON.parse(cfgText).rules.map((r) => r.name));
console.log(JSON.stringify({ states: states.length, bad }));
process.exit(bad.length ? 1 : 0);
"""
def expected_page(pick, again=0, clients=CLIENTS, limit=LIMIT, tries=TRIES):
"""What the page should show after these choices, worked out from sim.py."""
seed = SEED + again
runs = {rule: simulate(rule, seed, clients, limit, tries) for rule, _ in RULES}
rule = RULES[pick][0]
run = runs[rule]
ticks = max(len(run.served), 12) # the chart is at least 12 ticks wide
summary = lambda r: [r.refused_calls, r.gave_up, r.finished, r.busiest_retry_tick]
waits = {"once": "Call again on the very next tick.", "fixed": "Wait 4 ticks, every time.",
"backoff": "Wait 4 ticks, then twice as long each time, never more than 16.",
"jitter": "Pick a random wait from 1 tick up to the backoff wait: up to 4, then 8, never more than 16."}
return {
"rule": NAMES[rule], "pick": pick, "again": again, "columns": ticks,
"title": f"What happens when {clients} clients are told “too many requests”?",
"cap": f"A toy server, not a real one. It answers {limit} calls per tick (think of a tick as one second). All {clients} clients call at tick 0; a refused client tries again by the rule you pick.",
"what": waits[rule],
"big": f"{run.refused_calls} calls refused. " + (f"{run.gave_up} of {clients} clients gave up." if run.gave_up else "Every client was served."),
"sub": f"Last call at tick {run.finished}. Most calls in one retry tick: {run.busiest_retry_tick}.",
"note": f"Seed {seed}. A new run draws new random waits." if rule == "jitter" else "No random part, so every run is the same.",
"rows": [[name] + [str(v) for v in summary(runs[r])] for r, name in RULES],
"marks": ["rl-sel" if r == rule else "" for r, _ in RULES],
"sliders": [str(clients), str(limit), str(tries)],
"heights": [[100 * s / clients, 100 * r / clients] for s, r in zip(run.served, run.refused)],
}
PAGE_CHOICES = [
("opens on retry at once", dict(pick=0)),
("backoff with jitter", dict(pick=3)),
("fixed wait", dict(pick=1)),
("backoff", dict(pick=2)),
("jitter, two more random runs", dict(pick=3, again=2)),
("retry at once, a new run changes nothing", dict(pick=0, again=1)),
("jitter, 40 clients, 5 per tick", dict(pick=3, clients=40, limit=5)),
("backoff, 5 clients, 2 tries", dict(pick=2, clients=5, tries=2)),
("jitter, nobody refused", dict(pick=3, clients=8, limit=10)),
("a slider alone, no rule picked", dict(pick=0, clients=8)),
]
def test_widget_data_block_matches_sim():
"""Guards a sim.py edit that was not copied into the widget (run embed_widget.py)."""
assert embed(HTML) == HTML
def test_widget_sliders_hold_every_value_the_data_block_sets():
"""Guards a setting changed in sim.py past the end of its slider: the browser would clamp it and the page would show another run."""
cfg = config()
for key in ("clients", "limit", "tries"):
low, high = (int(v) for v in re.search(rf'id="rl-{key}" min="(\d+)" max="(\d+)"', HTML).groups())
assert low <= cfg[key] <= high, key
def test_every_element_the_script_looks_up_exists_inside_the_root():
"""Guards a widget that never starts: a lookup of an id that is not in the page throws in the browser."""
root = HTML[HTML.index('<div class="rl-root">'):HTML.index("<script")]
script = HTML[HTML.index("<script>"):]
lookups = set(re.findall(r"\$\('([\w-]+)'\)", script))
assert "['clients', 'limit', 'tries']" in script, "the slider key list has moved: update this test"
lookups |= {f"rl-{key}{suffix}" for key in ("clients", "limit", "tries") for suffix in ("", "-v")}
created = {"rl-pick-" + r["id"] for r in config()["rules"]} # made by the script, not in the page
missing = sorted(i for i in lookups if f'id="{i}"' not in root and i not in created)
assert lookups and not missing, missing
def test_widget_is_small_and_asks_for_nothing_online():
assert len(HTML.splitlines()) <= 300 and len(HTML.encode("utf-8")) < 40 * 1024
page = re.sub(r'<script type="application/json" id="rl-config">.*?</script>', "", HTML, flags=re.S)
for banned in ("http://", "https://", "fetch(", "XMLHttpRequest", "WebSocket", "sendBeacon", "import(", "Math.random"):
assert banned not in page, banned
assert " src=" not in page and "@import" not in page
def test_widget_has_no_form_element():
"""Guards a dead button: the lesson page loads widget.html in a sandboxed frame, where a form cannot submit."""
assert "<form" not in HTML and 'type="submit"' not in HTML
def test_widget_keeps_the_site_theme_listener_and_palette():
assert "d.type !== 'spicyshark:theme'" in HTML and "e.source !== parent" in HTML
for pair in ("light-dark(#ffffff, #0a0f17)", "light-dark(#ebf0f6, #131b28)", "light-dark(#0a1626, #f4f7fb)",
"light-dark(#44566b, #a3b1c4)", "light-dark(#d2dae4, #1c2635)", "light-dark(#66768a, #7a8a9f)",
"--green: light-dark(#00855F, #009E73)", "--orange: light-dark(#b36200, #E69F00)",
".rl-root .rl-ok { background: var(--green)", ".rl-root .rl-no { background: var(--orange)"):
assert pair in HTML, pair
assert not re.search(r"0072B2|56B4E9|D55E00", HTML, re.I)
def test_widget_keeps_extra_settings_in_a_closed_more_settings_block():
"""The lesson's brief: the point shows with four radios and one button; the sliders wait behind "More settings"."""
assert re.search(r"<details>\s*<summary>More settings</summary>", HTML) and "<details open" not in HTML
visible = HTML[:HTML.index("<details>")]
assert len(re.findall(r"<button\b", visible)) == 1 and 'type="range"' not in visible
def test_widget_rules_match_the_python_sim(tmp_path):
"""Guards the widget's JavaScript drifting from sim.py: the same numbers, runs and waits for every rule, seed and setting."""
numbers = []
for seed in (0, 1, 2, 3, 12345, 2 ** 31, 2 ** 32 - 1):
gen = random_numbers(seed)
numbers.append({"seed": seed, "values": [next(gen) for _ in range(200)]})
runs = []
for clients, limit, tries in ((CLIENTS, LIMIT, TRIES), (5, 1, 2), (40, 10, 10), (12, 2, 4), (30, 5, 3), (8, 10, 6), (40, 1, 10)):
for seed in range(1, 31):
for rule, _ in RULES:
run = simulate(rule, seed, clients, limit, tries)
runs.append({"rule": rule, "seed": seed, "clients": clients, "limit": limit, "tries": tries, "served": list(run.served),
"refused": list(run.refused), "gaveUp": run.gave_up,
"summary": {"refused": run.refused_calls, "gaveUp": run.gave_up, "finished": run.finished, "peak": run.busiest_retry_tick}})
from sim import wait_after
waits = [{"rule": rule, "refusals": k, "draw": draw, "wait": wait_after(rule, k, lambda: draw)}
for rule, _ in RULES for k in range(1, 7) for draw in (0.0, 0.2, 0.5, 0.999999)]
(tmp_path / "fixture.json").write_text(json.dumps({"numbers": numbers, "runs": runs, "waits": waits}), encoding="utf-8")
(tmp_path / "parity.mjs").write_text(PARITY, encoding="utf-8")
result = subprocess.run(["node", str(tmp_path / "parity.mjs"), str(tmp_path / "fixture.json"), str(WIDGET)], capture_output=True, text=True, timeout=120)
assert result.returncode == 0, result.stdout + result.stderr
assert json.loads(result.stdout) == {"numbers": 7, "runs": 7 * 30 * 4, "waits": 4 * 6 * 4, "bad": []}
def test_widget_page_shows_what_the_lesson_names(tmp_path):
"""Guards the part a reader sees: headline, second line, comparison table, the bars, the note beside the button and the sliders' wiring.
The lesson names these runs: retry at once (57 refused, 2 gave up), the fixed wait, backoff, and backoff with jitter on seed 1 (25 refused)."""
states = [{"name": name, **choice, **expected_page(**choice)} for name, choice in PAGE_CHOICES]
(tmp_path / "states.json").write_text(json.dumps({"states": states}), encoding="utf-8")
(tmp_path / "page.mjs").write_text(PAGE_TEST, encoding="utf-8")
result = subprocess.run(["node", str(tmp_path / "page.mjs"), str(WIDGET), str(tmp_path / "states.json")], capture_output=True, text=True, timeout=60)
assert result.returncode == 0, result.stdout + result.stderr
assert json.loads(result.stdout) == {"states": len(PAGE_CHOICES), "bad": []}
first = states[0]
assert (first["big"], first["rows"][0]) == ("57 calls refused. 2 of 20 clients gave up.", ["Retry at once", "57", "2", "5", "17"])
assert states[1]["rows"][3] == ["Backoff with jitter", "25", "0", "14", "7"]