code wiki / _hdl_build / nx_retry_policy_gate.nx
nx_retry_policy_gate.nx source
↩ module page · 202 lines · 11900 B
1// nx_retry_policy_gate.nx -- TEETH FOR THE RETRY PRIMITIVE, written because its jitter was ADVERTISED AND
2// INERT and nothing could see it.
3//
4// THE DEFECT (measured 2026-08-21, vocabulary census): nx_retry_policy_new set `p.jitter_enabled = 1`
5// while nx_retry_compute_wait_seconds performed NO randomization -- its own comment said "v1 uses
6// deterministic mid-jitter (multiply by 1.0)". A caller reading the flag believed the herd was
7// decorrelated while every retry landed on the same instant. It failed in the FLATTERING direction, so
8// nobody investigated, and the organ had NO GATE AT ALL -- the two facts are the same fact.
9//
10// THE TOOTH THAT MATTERS IS T2, AND IT IS BUILT TO FAIL AGAINST THE OLD CODE: two DIFFERENT callers
11// drawing at the SAME instant must get DIFFERENT delays. Under the 1.0-multiplier version they get the
12// same number, so T2 goes RED on the pre-fix source by construction -- that is what makes the GREEN worth
13// anything. Every other tooth here would have passed before the fix.
14//
15// No files, no fixtures, no clock: the subject is pure arithmetic and the seed is explicit, so this gate
16// is idempotent and cannot share a fixture with a production beat.
17// license_tier: ORIGINAL expect_exit: 0 No hw writes (Rule 26).
18import "nx_retry_policy.nx"
19import "nx_gate_verdict.nx"
20
21// The seed sweep. A jitter claim proven on ONE seed is a claim about one number -- a bound must hold over
22// the whole draw space, and a variance claim needs enough draws that agreement is not luck.
23const RG_SEEDS: i64 = 64
24// An attempt whose UNJITTERED delay is comfortably larger than a millisecond, so [d/2, d] has room to be
25// a real interval rather than a rounding artefact. Derived, not picked: at base 250 ms attempt 3 gives
26// 2000 ms, whose half-width is 1000 ms.
27const RG_ATTEMPT: i64 = 3
28const RG_NOW: i64 = 1787000000
29
30func rg_eq(a: i64, b: i64) -> i64 { if a == b { return 1 } return 0 }
31
32func rg_policy(hk: i64, attempt: i64, jit: i64) -> *RetryPolicy {
33 let p: *RetryPolicy = nx_retry_policy_new(hk)
34 p.current_attempt = attempt
35 p.jitter_enabled = jit
36 return p
37}
38
39func main() -> i64 {
40 let ctr: *i64 = gv_ctr()
41 gv_head("nx_retry_policy_gate -- the jitter is PERFORMED, not advertised" as *u8)
42
43 // ---- T1 ANTI-VACUITY, FIRST: the subject must produce a non-trivial delay at all. A gate whose
44 // subject returns 0 everywhere would score 100 percent on every bound below.
45 let base: *RetryPolicy = rg_policy(1, RG_ATTEMPT, 0)
46 let unjittered: i64 = nx_retry_compute_wait_ms(base, 0)
47 gv_puts(" unjittered wait_ms at attempt " as *u8); gv_num(RG_ATTEMPT)
48 gv_puts(" = " as *u8); gv_num(unjittered); gv_puts("\n" as *u8)
49 var t1: i64 = 0
50 if unjittered > 1 { if unjittered <= base.max_wait_seconds * NX_RETRY_MS_PER_SEC { t1 = 1 } }
51 gv_check("T1 ANTI-VACUITY: the unjittered backoff is a real, in-range delay (>1ms and within the cap), so every bound below is measured against something" as *u8, t1, ctr)
52
53 // ---- T2 THE ONE THAT FAILS AGAINST THE 1.0-MULTIPLIER VERSION ----
54 // Two DIFFERENT callers, SAME instant. This is the entire point of jitter and the exact thing the old
55 // code did not do. Counting DISTINCT values (not merely "a != b") so a two-valued degenerate draw
56 // cannot pass either.
57 var distinct: i64 = 0
58 var i: i64 = 0
59 while i < RG_SEEDS {
60 let pi: *RetryPolicy = rg_policy(i, RG_ATTEMPT, 1)
61 let wi: i64 = nx_retry_compute_wait_ms(pi, nx_retry_seed(pi, RG_NOW))
62 var seen: i64 = 0
63 var j: i64 = 0
64 while j < i {
65 let pj: *RetryPolicy = rg_policy(j, RG_ATTEMPT, 1)
66 let wj: i64 = nx_retry_compute_wait_ms(pj, nx_retry_seed(pj, RG_NOW))
67 if wj == wi { seen = 1; j = i }
68 j = j + 1
69 }
70 if seen == 0 { distinct = distinct + 1 }
71 i = i + 1
72 }
73 gv_puts(" distinct delays across " as *u8); gv_num(RG_SEEDS)
74 gv_puts(" callers at ONE instant = " as *u8); gv_num(distinct); gv_puts("\n" as *u8)
75 gv_bite("T2 NO THUNDERING HERD: 64 different callers drawing at the SAME instant get MANY distinct delays -- this is the tooth that goes RED against the multiply-by-1.0 version, where all 64 collapse to one value" as *u8,
76 rg_eq(distinct > RG_SEEDS / 2, 1), rg_eq(distinct, 1), ctr)
77
78 // ---- T3 THE BOUND, OVER THE WHOLE SWEEP, NOT ONE DRAW ----
79 var inband: i64 = 0
80 var below: i64 = 0
81 var above: i64 = 0
82 i = 0
83 while i < RG_SEEDS {
84 let p3: *RetryPolicy = rg_policy(i, RG_ATTEMPT, 1)
85 let w: i64 = nx_retry_compute_wait_ms(p3, nx_retry_seed(p3, RG_NOW))
86 if w > unjittered { above = above + 1 } else {
87 if w < unjittered / 2 { below = below + 1 } else { inband = inband + 1 }
88 }
89 i = i + 1
90 }
91 gv_puts(" band check: inband=" as *u8); gv_num(inband)
92 gv_puts(" below_half=" as *u8); gv_num(below)
93 gv_puts(" above_d=" as *u8); gv_num(above)
94 gv_puts(" (partition sums to " as *u8); gv_num(inband + below + above); gv_puts(")\n" as *u8)
95 gv_check("T3 EVERY draw lies in [d/2, d] over the whole 64-seed sweep, and the three-way partition SUMS to the sweep size (a bound proven on one draw is a claim about one number)" as *u8,
96 rg_eq(inband, RG_SEEDS), ctr)
97
98 // ---- T4 neg-control: DISABLING THE FLAG MUST CHANGE THE ANSWER ----
99 // Without this, a jitter function that ignored jitter_enabled would pass T2 and T3 and the flag would
100 // be decorative in the other direction.
101 var off_same: i64 = 1
102 i = 0
103 while i < RG_SEEDS {
104 let p4: *RetryPolicy = rg_policy(i, RG_ATTEMPT, 0)
105 if nx_retry_compute_wait_ms(p4, nx_retry_seed(p4, RG_NOW)) != unjittered { off_same = 0 }
106 i = i + 1
107 }
108 gv_check("neg-control-T4 jitter_enabled=0 returns the EXACT capped exponential for all 64 seeds -- the flag is what causes the spread, so it is not decorative in either direction" as *u8,
109 off_same, ctr)
110
111 // ---- T5 THE FLAG TELLS THE TRUTH ON A FRESH POLICY ----
112 // The original defect stated as one predicate: the default says jitter is ON, and the default really
113 // does randomize. Either half alone was true before the fix; the conjunction was not.
114 let fresh: *RetryPolicy = nx_retry_policy_new(7)
115 fresh.current_attempt = RG_ATTEMPT
116 let fa: i64 = nx_retry_compute_wait_ms(fresh, nx_retry_seed(fresh, RG_NOW))
117 let other: *RetryPolicy = nx_retry_policy_new(7000)
118 other.current_attempt = RG_ATTEMPT
119 let fb: i64 = nx_retry_compute_wait_ms(other, nx_retry_seed(other, RG_NOW))
120 var t5: i64 = 0
121 if fresh.jitter_enabled == 1 { if fa != fb { t5 = 1 } }
122 gv_bite("T5 THE DEFAULT POLICY'S FLAG MATCHES ITS BEHAVIOUR: jitter_enabled reads 1 AND two default policies really do draw differently (before the fix the flag read 1 and the draws were identical)" as *u8,
123 t5, rg_eq(fa, fb), ctr)
124
125 // ---- T6 THE CAP STILL BINDS AFTER JITTERING ----
126 var capped_ok: i64 = 1
127 i = 0
128 while i < RG_SEEDS {
129 let p6: *RetryPolicy = rg_policy(i, RG_ATTEMPT + 20, 1)
130 let w6: i64 = nx_retry_compute_wait_ms(p6, nx_retry_seed(p6, RG_NOW))
131 if w6 > p6.max_wait_seconds * NX_RETRY_MS_PER_SEC { capped_ok = 0 }
132 i = i + 1
133 }
134 gv_check("T6 a jittered draw never exceeds max_wait_seconds even at a deep attempt where the raw exponential would overflow the cap" as *u8, capped_ok, ctr)
135
136 // ---- T7/T8 RETRY-AFTER IS A FLOOR, AND THE FLOOR IS NOT INERT ----
137 // RFC 9110 says honour the header; a bare scalar synchronises the herd. So the jitter goes ABOVE the
138 // value, never below. T7 proves the direction, T8 proves it actually moves -- without T8 a function
139 // that returned the value verbatim would pass T7 forever, which is the original defect relocated.
140 let ra: i64 = 60
141 var never_early: i64 = 1
142 var moved: i64 = 0
143 i = 0
144 while i < RG_SEEDS {
145 let p7: *RetryPolicy = nx_retry_policy_new(i)
146 nx_retry_apply_retry_after(p7, ra, i)
147 if p7.next_wait_ms < ra * NX_RETRY_MS_PER_SEC { never_early = 0 }
148 if p7.next_wait_ms > ra * NX_RETRY_MS_PER_SEC { moved = moved + 1 }
149 i = i + 1
150 }
151 gv_puts(" retry-after floor=" as *u8); gv_num(ra * NX_RETRY_MS_PER_SEC)
152 gv_puts("ms seeds_above_floor=" as *u8); gv_num(moved); gv_puts("\n" as *u8)
153 gv_check("T7 Retry-After is a FLOOR: no seed ever produces a wait EARLIER than the server asked for (jittering downward here would violate the header)" as *u8, never_early, ctr)
154 gv_bite("T8 and the floor is NOT honoured verbatim: some seeds land strictly above it, so the herd told the same Retry-After is still spread" as *u8,
155 rg_eq(moved > 0, 1), rg_eq(moved, 0), ctr)
156
157 // ---- T9 THE SIZE CONSTANT AGREES WITH THE STRUCT ----
158 // It did not: the constant read 96 ("12 fields") against 13 real fields, so the last field was written
159 // 8 bytes past the allocation. mmap page-rounding hid it, which is why it survived. Writing a sentinel
160 // to the LAST field and reading it back proves the allocation actually covers the layout in use.
161 let sz: *RetryPolicy = nx_retry_policy_new(11)
162 sz.last_error_class = NX_RETRY_ERROR_TLS_FAIL
163 sz.next_wait_ms = 123456
164 var t9: i64 = 0
165 if NX_RETRY_POLICY_BYTES == NX_RETRY_POLICY_FIELDS * 8 {
166 if sz.last_error_class == NX_RETRY_ERROR_TLS_FAIL { if sz.next_wait_ms == 123456 { t9 = 1 } }
167 }
168 gv_puts(" policy bytes=" as *u8); gv_num(NX_RETRY_POLICY_BYTES)
169 gv_puts(" fields=" as *u8); gv_num(NX_RETRY_POLICY_FIELDS); gv_puts("\n" as *u8)
170 gv_check("T9 NX_RETRY_POLICY_BYTES is DERIVED from the field count and the last two fields round-trip a sentinel -- a hand-counted length beside a struct is a second copy of its shape and the two drift" as *u8, t9, ctr)
171
172 // ---- T10 THE SEED CARRIES PER-CALLER IDENTITY ----
173 // A seed of `now` alone would move every client together and change nothing; this is the conjunct that
174 // makes T2 possible at all, tested directly so a T2 failure can be localised.
175 let s1: *RetryPolicy = nx_retry_policy_new(100)
176 let s2: *RetryPolicy = nx_retry_policy_new(200)
177 gv_bite("T10 the seed is per-CALLER, not per-instant: two descriptor_hk values give different seeds at the same now_unix" as *u8,
178 rg_eq(nx_retry_seed(s1, RG_NOW) != nx_retry_seed(s2, RG_NOW), 1),
179 rg_eq(nx_retry_seed(s1, RG_NOW), nx_retry_seed(s2, RG_NOW)), ctr)
180
181 // ---- T11 THE DECISION PATHS ARE UNCHANGED BY THIS WORK ----
182 let d: *RetryPolicy = nx_retry_policy_new(3)
183 var t11: i64 = 0
184 if nx_retry_policy_decide(d, NX_RETRY_ERROR_401_AUTH, RG_NOW) == NX_RETRY_NON_RETRYABLE {
185 let d2: *RetryPolicy = nx_retry_policy_new(3)
186 if nx_retry_policy_decide(d2, NX_RETRY_ERROR_5XX_SERVER, RG_NOW) == NX_RETRY_WAIT_AND_RETRY {
187 if d2.next_wait_ms > 0 { t11 = 1 }
188 }
189 }
190 gv_check("T11 REGRESSION: a non-retryable class still refuses, a retryable class still says WAIT_AND_RETRY, and the decision now populates next_wait_ms rather than leaving the caller with only the coarse seconds view" as *u8, t11, ctr)
191
192 // ---- T12 ONE DRAW FEEDS BOTH VIEWS ----
193 let d3: *RetryPolicy = nx_retry_policy_new(9)
194 nx_retry_policy_decide(d3, NX_RETRY_ERROR_TIMEOUT, RG_NOW)
195 let expect_s: i64 = (d3.next_wait_ms + NX_RETRY_MS_PER_SEC - 1) / NX_RETRY_MS_PER_SEC
196 gv_check("T12 next_wait_seconds is the ROUNDED-UP view of the very next_wait_ms that was drawn, so the two fields can never disagree about which delay was chosen (a second draw here would be a second ruler)" as *u8,
197 rg_eq(d3.next_wait_seconds, expect_s), ctr)
198
199 let rc: i64 = gv_verdict("RETRY-POLICY-GATE" as *u8, ctr, "the retry primitive's advertised jitter is performed, bounded, and directional" as *u8)
200 sys_exit(rc)
201 return rc
202}