# The measured set: one bad value, four designs, and the distance it travels

Four designs of the same rate limiter. All four answer identically while the inputs are honest, and
`BaseTest.java` passes against all four. The comparison is between working designs.

- `a/` puts the checks at the point of use. Each consumer validates what it reads.
- `a-validate/` puts the same rules in a `Decision.validate()` method, and `Limiter` calls it on the
  deny path.
- `b/` puts them in `Decision`'s compact constructor. No consumer validates anything.
- `b-normalising/` puts them in the same constructor and has them repair the value instead of
  refusing it.

The instrument is not diff size. For an invariant the question is **how far a bad value gets before
anything notices**, so `Probe.java` counts the calls and reports what the thing that noticed said.

## The moment this measures, in an interviewer's words

> Two threads hit the same key. Your check-then-charge is not under one lock, so one of them checks
> while 97 requests are counted and charges when 103 are. What does your API return for that
> request?

That is not a hypothetical. It is the bug the rate-limiter stress suite was built to catch, and
`corpus/rate-limiter/reference/DECISION_LOG.md` records how it surfaced:

> several of the observed failures on the first variant were not assertions at all but
> `IllegalArgumentException: remaining cannot be negative, got -3` thrown by **`Decision`'s own
> compact constructor**.

Nobody wrote a test for that. The value type refused the value.

## The numbers

Run it yourself:

```
node lessons/A5/contrast/measure.mjs
```

Real output, from exactly these directories:

```
a               BaseTest 2/2   InvariantTest 0/2
a-validate      BaseTest 2/2   InvariantTest 0/2
b               BaseTest 2/2   InvariantTest 2/2
b-normalising   BaseTest 2/2   InvariantTest 0/2
```

`BaseTest.java` is the shared behaviour. `InvariantTest.java` is the suite that separates the four,
and both of its assertions are about construction alone. No consumer appears in either, so a design
that checks at the point of use cannot pass by being careful somewhere else.

| Scenario | `a/` point of use | `a-validate/` method | `b/` constructor | `b-normalising/` repairs |
|---|---|---|---|---|
| torn count, `remaining` = -3 | **never detected**, 7 calls | **never detected**, 7 calls | **2 calls** | **never detected**, 7 calls |
| stale boundary, through the responder | 6 calls | 3 calls | **2 calls** | never detected |
| stale boundary, through the retrying caller | 5 calls | 3 calls | **2 calls** | never detected |
| size | 114 normalised lines | 127 | 124 | 122 |

The two calls in `b/` are `Limiter.decide` and `Decision.allowed`. The second one is the throw.

## Scenario 1, the torn count, in full

`a/`, unedited:

```
scenario 1   torn count: checked at 97 used, charged at 103, limit 100
  detected  no
  calls     7  [Limiter.decide, Decision.allowed, Limiter.audit, Responder.respond, Responder.status, Responder.headers, Responder.body]
  outcome   audit [rule=hourly allowed=true remaining=-3 retryAfter=PT0S]  response [200 OK | X-RateLimit-Remaining: -3, Retry-After: 0 | 103% of quota used]
```

Nothing threw. The customer got a `200`, a header saying they have minus three requests left, and a
usage figure of 103%. The audit line is already written. There is no exception, no alert and no test
failure anywhere in the system.

`b/`, unedited:

```
scenario 1   torn count: checked at 97 used, charged at 103, limit 100
  detected  yes, after 2 call(s)
  calls     [Limiter.decide, Decision.allowed]
  threw     java.lang.IllegalArgumentException
  message   remaining cannot be negative, got -3
  blamed    Decision.<init>(Decision.java:15)
```

Two calls, and the top frame is the constructor of the type that owns the rule. The audit line was
never written, because the decision never existed.

**That is the claim of A5 in one pair of outputs.** The same wrong number, produced by the same bug,
is a silent `200` in one design and a thrown exception in the other. Nothing in `a/` is incompetent.
Its consumers check what they use. The `-3` slips through because printing a number cannot fail and
dividing by a limit cannot fail, so no consumer had a reason to look.

## `a-validate/`, which is the design most people already write

The rules in `a-validate/Decision.validate()` are character for character `b/`'s rules. The method is
public, it is on the type that owns them, and `Limiter.decide` calls it. On the deny path.

```
scenario 2   stale window boundary, through the HTTP responder
  detected  yes, after 3 call(s)
  calls     [Limiter.decide, Decision.denied, Decision.validate]
  message   a denial must say when to come back, and it must be later than now, got PT-3S
  blamed    Decision.validate(Decision.java:19)
```

That works. The message is the good one and the blame is in the right class, one call later than
`b/`. Where the design fails is scenario 1:

```
scenario 1   torn count: checked at 97 used, charged at 103, limit 100
  detected  no
  calls     7  [Limiter.decide, Decision.allowed, Limiter.audit, Responder.respond, Responder.status, Responder.headers, Responder.body]
  outcome   audit [rule=hourly allowed=true remaining=-3 retryAfter=PT0S]  response [200 OK | X-RateLimit-Remaining: -3, Retry-After: 0 | 103% of quota used]
```

`Decision.validate` is not in that call list. It exists, it is correct, and the allow path does not
call it. Nobody decided to skip it. The author was thinking about the retry hint, wrote the call on
the branch where the retry hint is set, and moved on. No compiler mentions the other branch.

Now the size: `a-validate/` is **127** normalised lines against `b/`'s **124**. It costs three lines
more than putting the same rules in the constructor, and it misses the case a concurrency bug
actually used. That is the whole argument against a `validate()` method, in two numbers from one
command.

## Scenario 2 and 3, and why the same bug has two messages in `a/`

The stale boundary produces a negative wait. Two consumers see it, and each one fails differently.

Through the HTTP responder, which does check:

```
  detected  yes, after 6 call(s)
  threw     java.lang.IllegalArgumentException
  message   Retry-After cannot be negative: PT-3S
  blamed    Responder.headers(Responder.java:26)
```

Through the internal caller, which does not:

```
  detected  yes, after 5 call(s)
  threw     java.lang.IllegalArgumentException
  message   timeout value is negative
  blamed    java.base/java.lang.Thread.sleep(Thread.java:500)
```

Same rule, same violation, two messages. One names a header and blames `Responder`. The other comes
from `java.lang.Thread` and mentions neither rate limiting nor the rule that produced it. Neither
names the limiter whose window boundary went stale, because neither consumer knows about it.

`b/` reports both paths the same way, in two calls:

```
  message   a denial must say when to come back, and it must be later than now, got PT-3S
  blamed    Decision.<init>(Decision.java:18)
```

**The count is the cheaper half of this finding.** The expensive half is where the blame lands. In
`a/` the stack trace points at the class that noticed, which is never the class that was wrong. Every
one of those investigations starts in the wrong file.

## The measurement that goes the wrong way

Here is the review comment that produces `b-normalising/`:

> A negative `Retry-After` took the checkout service down last quarter. Do not make every caller
> handle it. Make the type tolerant.

It is a reasonable request, and the change is two lines: `remaining = Math.max(0L, remaining)` and a
one-second fallback wait. The invariant stays exactly where `b/` put it. Only the response to a
violation changes, from refusing to repairing.

```
scenario 1   torn count: checked at 97 used, charged at 103, limit 100
  detected  no
  calls     7
  outcome   audit [rule=hourly allowed=true remaining=0 retryAfter=PT0S]  response [200 OK | X-RateLimit-Remaining: 0, Retry-After: 0 | 100% of quota used]
```

Compare that audit line with `a/`'s. In `a/` the log says `remaining=-3`, which is a number a human
can find and reason about. Here it says `remaining=0` and the response reads `100% of quota used`,
which is indistinguishable from a customer who has honestly spent their whole hourly quota.

`b-normalising/` is the only one of the four where the torn count leaves no trace at all. Placing
the check correctly and then softening it is worse than not placing it, and it costs 8 more lines
than `a/` to be worse.

Scenario 3 makes the same point louder. The fallback wait is one second, so the retrying caller
sleeps for a second nobody asked for and then retries. No error, no log line, one second of latency
per affected request, and the window boundary is still stale.

## The alternatives, so the choice is a choice

**Could `a/` catch the torn count by checking in `Responder`?** Yes, and it would then be caught at
call 6 instead of never. That check has to be written in every consumer that reads `remaining`, and
there are three in this tiny tree: the audit line, the header and the usage figure. The count of
consumers is what grows, and none of them can be made to check by the compiler.

**Is `b/` free?** No. It is 124 normalised lines against `a/`'s 114. Ten lines is the price, and
`when-not.md` names the two occasions when it buys nothing. It is worth noticing that `b/` is the
second cheapest of the four designs, and the only one that catches everything.

**Would calling `validate()` from every construction site fix `a-validate/`?** Yes, and the
construction sites are the problem. There are two in this tree. The corpus reference has three, in
`KeyBudget.java` and twice in `RateLimiter.java`, and nothing checks that the number of `validate()`
calls matches. `b/` has one site by construction, because the constructor is the only way to make
the object.

**Could `b/` log instead of throwing?** That is `b-normalising/` with a print statement, and the
measurement above is the answer. A log line in a system that emits millions of them is not a
detection. Throwing is what makes it one.

## What this set does not show

The distances here are 2 against 7 in a tree of five small files. In a real service the consumer side
is a request handler, a metrics exporter, a billing pipeline and a support console, and the
equivalent scenario 1 number is not 7. It is however many calls happen between a torn subtraction and
somebody noticing a support ticket about a 103% figure.

It also does not show a case where construction-time placement is wrong. That case exists, and it is
`when-not.md`.
