code wiki / (root) / nx_retry_policy.nx

nx_retry_policy.nx source

↩ module page · 244 lines · 9881 B

1// nx_retry_policy.nx -- exponential backoff + jitter + max-attempts. 2// 3// module: nishi-core.ingest.retry_policy 4// depends: nishi-core.io.syscalls, nishi-core.io.iso8601 5// disk_kb: 5 6// capability: CORE_IO 7// 8// license_tier: PUBLIC_NISHI_SUBSTRATE 9// genealogy_id: aws_architecture_blog_exponential_backoff_jitter_2015 + 10// polly_microsoft_resilience_library + 11// google_sre_book_retry_amplification + 12// nishi_ingestion_s_class_cardinal_2026 13// 14// Retry primitive with exponential backoff + jitter + max-attempts. 15// Composes against nx_circuit_breaker (trips when retries exhausted). 16// Substrate Cardinal 14 graceful-degradation: every NX-INGEST adapter 17// invokes this primitive to decide whether to retry a failed 18// upstream call + how long to wait. 19// 20// ===== Why exponential backoff + jitter =========================== 21// 22// AWS Architectural Blog 2015: when many clients retry simultaneously 23// (thundering herd), they amplify the upstream load just as upstream 24// is recovering. Exponential backoff alone DOESN'T fix this — all 25// clients still retry at the same exponentially-spaced times. 26// JITTER (random offset within the backoff window) breaks the 27// synchronization. 28// 29// Google SRE book: retry amplification is the #1 cause of cascading 30// outages. Bounded max-attempts + jittered backoff prevents 31// substrate from contributing to upstream failure. 32// 33// ===== Backoff formula ============================================ 34// 35// attempt_n: wait base * 2^n seconds, capped at max_wait_seconds 36// jitter: multiply by random(0.5, 1.5) — "full jitter" variant 37// total: sum across attempts capped at attempts_cap 38// 39// Defaults: 40// base_ms = 250 41// max_wait_seconds = 60 42// max_attempts = 7 (totals ~3 min cumulative wait) 43 44// nx_safety_envelope: 45// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 46// sil_target: SIL1 47// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 48// verdict: NOT_YET_EVALUATED 49 50import "nx_syscalls.nx" 51import "nx_iso8601.nx" 52 53// ===== Verdict ==================================================== 54 55const NX_RETRY_PROCEED: i64 = 1 // try the upstream call 56const NX_RETRY_WAIT_AND_RETRY: i64 = 2 // wait then retry 57const NX_RETRY_EXHAUSTED: i64 = 3 // max attempts reached; give up 58const NX_RETRY_NON_RETRYABLE: i64 = 4 // error class not retryable (auth fail etc.) 59const NX_RETRY_CIRCUIT_OPEN: i64 = 5 // breaker says short-circuit 60 61func nx_retry_verdict_name(v: i64) -> *u8 { 62 if v == NX_RETRY_PROCEED { return "PROCEED" } 63 if v == NX_RETRY_WAIT_AND_RETRY { return "WAIT_AND_RETRY" } 64 if v == NX_RETRY_EXHAUSTED { return "EXHAUSTED" } 65 if v == NX_RETRY_NON_RETRYABLE { return "NON_RETRYABLE" } 66 if v == NX_RETRY_CIRCUIT_OPEN { return "CIRCUIT_OPEN" } 67 return "UNKNOWN" 68} 69 70// ===== Error-class sealed enum ==================================== 71// 72// Different upstream-error classes have different retryability. 73// Substrate doesn't retry 4xx (caller error); does retry 5xx + 74// timeouts. 75 76const NX_RETRY_ERROR_TIMEOUT: i64 = 1 // retryable 77const NX_RETRY_ERROR_CONN_REFUSED: i64 = 2 // retryable 78const NX_RETRY_ERROR_5XX_SERVER: i64 = 3 // retryable (server-side) 79const NX_RETRY_ERROR_429_RATE_LIMIT: i64 = 4 // retryable with Retry-After 80const NX_RETRY_ERROR_4XX_CLIENT: i64 = 5 // NOT retryable (our request bad) 81const NX_RETRY_ERROR_401_AUTH: i64 = 6 // NOT retryable (need new creds) 82const NX_RETRY_ERROR_403_FORBIDDEN: i64 = 7 // NOT retryable 83const NX_RETRY_ERROR_404_NOT_FOUND: i64 = 8 // NOT retryable (doesn't exist) 84const NX_RETRY_ERROR_TLS_FAIL: i64 = 9 // retryable (transient TLS issue) 85const NX_RETRY_ERROR_DNS_FAIL: i64 = 10 // retryable 86const NX_RETRY_ERROR_NETWORK_OTHER: i64 = 11 // retryable conservatively 87 88func nx_retry_error_class_name(c: i64) -> *u8 { 89 if c == NX_RETRY_ERROR_TIMEOUT { return "TIMEOUT" } 90 if c == NX_RETRY_ERROR_CONN_REFUSED { return "CONN_REFUSED" } 91 if c == NX_RETRY_ERROR_5XX_SERVER { return "5XX_SERVER" } 92 if c == NX_RETRY_ERROR_429_RATE_LIMIT { return "429_RATE_LIMIT" } 93 if c == NX_RETRY_ERROR_4XX_CLIENT { return "4XX_CLIENT" } 94 if c == NX_RETRY_ERROR_401_AUTH { return "401_AUTH" } 95 if c == NX_RETRY_ERROR_403_FORBIDDEN { return "403_FORBIDDEN" } 96 if c == NX_RETRY_ERROR_404_NOT_FOUND { return "404_NOT_FOUND" } 97 if c == NX_RETRY_ERROR_TLS_FAIL { return "TLS_FAIL" } 98 if c == NX_RETRY_ERROR_DNS_FAIL { return "DNS_FAIL" } 99 if c == NX_RETRY_ERROR_NETWORK_OTHER { return "NETWORK_OTHER" } 100 return "UNKNOWN" 101} 102 103func nx_retry_error_is_retryable(c: i64) -> i64 { 104 if c == NX_RETRY_ERROR_4XX_CLIENT { return 0 } 105 if c == NX_RETRY_ERROR_401_AUTH { return 0 } 106 if c == NX_RETRY_ERROR_403_FORBIDDEN { return 0 } 107 if c == NX_RETRY_ERROR_404_NOT_FOUND { return 0 } 108 return 1 109} 110 111// ===== RetryPolicy struct ========================================= 112 113struct RetryPolicy { 114 policy_hk: i64, 115 descriptor_hk: i64, 116 // Configuration (Cardinal 11: data-driven, not magic numbers) 117 base_wait_ms: i64, // initial wait window 118 max_wait_seconds: i64, // cap per-attempt wait 119 max_attempts: i64, // total attempts allowed (1 = no retry) 120 jitter_enabled: i64, // 1 = full jitter, 0 = deterministic 121 // Per-call state 122 current_attempt: i64, // 0-indexed 123 next_wait_seconds: i64, // computed by last invocation 124 total_wait_seconds: i64, // cumulative across attempts 125 // Diagnostics 126 n_total_invocations: i64, 127 n_total_retries: i64, 128 n_total_exhausted: i64, 129 last_error_class: i64, 130} 131 132const NX_RETRY_POLICY_BYTES: i64 = 96 // 12 fields * 8 bytes 133 134// ===== Defaults =================================================== 135 136const NX_RETRY_DEFAULT_BASE_WAIT_MS: i64 = 250 137const NX_RETRY_DEFAULT_MAX_WAIT_SEC: i64 = 60 138const NX_RETRY_DEFAULT_MAX_ATTEMPTS: i64 = 7 139 140// ===== Constructor ================================================ 141 142func nx_retry_policy_new(descriptor_hk: i64) -> *RetryPolicy { 143 let raw: *u8 = sys_mmap(NX_RETRY_POLICY_BYTES) 144 let p: *RetryPolicy = raw as *RetryPolicy 145 p.policy_hk = 0 146 p.descriptor_hk = descriptor_hk 147 p.base_wait_ms = NX_RETRY_DEFAULT_BASE_WAIT_MS 148 p.max_wait_seconds = NX_RETRY_DEFAULT_MAX_WAIT_SEC 149 p.max_attempts = NX_RETRY_DEFAULT_MAX_ATTEMPTS 150 p.jitter_enabled = 1 151 p.current_attempt = 0 152 p.next_wait_seconds = 0 153 p.total_wait_seconds = 0 154 p.n_total_invocations = 0 155 p.n_total_retries = 0 156 p.n_total_exhausted = 0 157 p.last_error_class = 0 158 return p 159} 160 161// ===== Compute next wait (exponential backoff) =================== 162// 163// wait_ms = min(base * 2^attempt, max_wait_ms) 164// If jitter: wait_ms = wait_ms * random(0.5, 1.5) 165// 166// v1 uses deterministic mid-jitter (multiply by 1.0); v1.1 wires 167// against nx_prng for true full-jitter when nx_prng matures. 168 169func nx_retry_compute_wait_seconds(p: *RetryPolicy) -> i64 { 170 if p == 0 as *RetryPolicy { return 0 } 171 if p.current_attempt < 0 { return 0 } 172 // 2^attempt with cap at max_wait 173 var shift: i64 = p.current_attempt 174 if shift > 30 { shift = 30 } // avoid overflow on i64 175 let exp_ms: i64 = p.base_wait_ms << shift 176 let max_ms: i64 = p.max_wait_seconds * 1000 177 var wait_ms: i64 = exp_ms 178 if wait_ms > max_ms { wait_ms = max_ms } 179 // v1 deterministic; jitter via nx_prng in v1.1 180 return (wait_ms + 999) / 1000 // round up to whole seconds 181} 182 183// ===== Decide next action (the verdict entry-point) ============== 184// 185// Called after each upstream attempt fails. Caller passes the 186// error class; substrate returns verdict + populates 187// p.next_wait_seconds for the caller to sleep before retry. 188 189func nx_retry_policy_decide( 190 p: *RetryPolicy, 191 error_class: i64, 192 now_unix: i64 193) -> i64 { 194 if p == 0 as *RetryPolicy { return NX_RETRY_EXHAUSTED } 195 p.n_total_invocations = p.n_total_invocations + 1 196 p.last_error_class = error_class 197 198 // Non-retryable error classes 199 if nx_retry_error_is_retryable(error_class) == 0 { 200 return NX_RETRY_NON_RETRYABLE 201 } 202 203 // Increment attempt counter 204 p.current_attempt = p.current_attempt + 1 205 206 // Exhausted? 207 if p.current_attempt >= p.max_attempts { 208 p.n_total_exhausted = p.n_total_exhausted + 1 209 return NX_RETRY_EXHAUSTED 210 } 211 212 // Compute next wait 213 p.next_wait_seconds = nx_retry_compute_wait_seconds(p) 214 p.total_wait_seconds = p.total_wait_seconds + p.next_wait_seconds 215 p.n_total_retries = p.n_total_retries + 1 216 return NX_RETRY_WAIT_AND_RETRY 217} 218 219// ===== Reset on successful call =================================== 220// 221// After a successful upstream call, the retry policy state is reset 222// so the next failure starts the backoff sequence fresh. 223 224func nx_retry_policy_record_success(p: *RetryPolicy) -> i64 { 225 if p == 0 as *RetryPolicy { return -1 } 226 p.current_attempt = 0 227 p.next_wait_seconds = 0 228 return 0 229} 230 231// ===== RFC 6585 Retry-After integration ========================== 232// 233// When upstream returns 429 with Retry-After header, that value 234// SUPERSEDES our exponential backoff (RFC mandates respecting it). 235// Caller passes the retry-after seconds; policy honors it for next 236// wait. 237 238func nx_retry_apply_retry_after(p: *RetryPolicy, retry_after_seconds: i64) -> i64 { 239 if p == 0 as *RetryPolicy { return -1 } 240 if retry_after_seconds <= 0 { return -1 } 241 p.next_wait_seconds = retry_after_seconds 242 p.total_wait_seconds = p.total_wait_seconds + retry_after_seconds 243 return 0 244}