1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Family {
45 Compile,
47 Runtime,
49 TxRevert,
51 Backend,
53 Core,
55}
56
57pub const FAMILIES: [Family; 5] = [
59 Family::Compile,
60 Family::Runtime,
61 Family::TxRevert,
62 Family::Backend,
63 Family::Core,
64];
65
66impl Family {
67 pub fn of(code: u16) -> Option<Family> {
69 match code {
70 1..=999 => Some(Family::Compile),
71 1000..=1999 => Some(Family::Runtime),
72 2000..=2999 => Some(Family::TxRevert),
73 3000..=3999 => Some(Family::Backend),
74 4000..=4999 => Some(Family::Core),
75 _ => None,
76 }
77 }
78
79 pub fn label(self) -> &'static str {
81 match self {
82 Family::Compile => "compile",
83 Family::Runtime => "runtime",
84 Family::TxRevert => "tx-revert",
85 Family::Backend => "backend",
86 Family::Core => "core",
87 }
88 }
89}
90
91#[derive(Debug, Clone, Copy)]
95pub struct ErrorCode {
96 pub code: u16,
98 pub family: Family,
100 pub meaning: &'static str,
102 pub hint: &'static str,
104}
105
106impl ErrorCode {
107 pub fn label(&self) -> String {
109 format!("LH{:04}", self.code)
110 }
111}
112
113pub fn fmt_label(code: u16) -> String {
116 format!("LH{code:04}")
117}
118
119pub const UNEXPECTED_BYTE: u16 = 1;
123pub const UNTERMINATED_STRING: u16 = 2;
125pub const UNKNOWN_ESCAPE: u16 = 3;
127pub const BAD_CHAR_LITERAL: u16 = 4;
129pub const BAD_NUMBER: u16 = 5;
131
132pub const UNEXPECTED_TOKEN: u16 = 100;
135pub const EXPECTED_ITEM: u16 = 101;
137pub const EXPECTED_TYPE: u16 = 102;
139pub const EXPECTED_EXPRESSION: u16 = 103;
141pub const EXPECTED_PATTERN: u16 = 104;
143pub const MISSING_SEMICOLON: u16 = 105;
145pub const INVALID_ASSIGN_TARGET: u16 = 106;
147pub const NESTING_TOO_DEEP: u16 = 107;
149
150pub const UNKNOWN_TYPE: u16 = 200;
153pub const UNDEFINED_VARIABLE: u16 = 201;
155pub const UNKNOWN_FUNCTION: u16 = 202;
157pub const ARITY_MISMATCH: u16 = 203;
159pub const TYPE_MISMATCH: u16 = 204;
161pub const NOT_MUTABLE: u16 = 205;
163pub const BAD_FIELD_ACCESS: u16 = 206;
165pub const BAD_INDEX: u16 = 207;
167pub const BAD_CAST: u16 = 208;
169pub const UNKNOWN_STRUCT: u16 = 209;
171
172pub const UNSUPPORTED_FEATURE: u16 = 300;
175pub const UNKNOWN_HOST_IMPORT: u16 = 301;
177pub const NO_ENTRY: u16 = 302;
179pub const OVERSIZE: u16 = 303;
181
182pub const FRAME_TIMEOUT: u16 = 1001;
185pub const WASM_TRAP: u16 = 1002;
187pub const INSTANTIATE_FAILED: u16 = 1003;
189pub const NO_ENTRY_RUNTIME: u16 = 1004;
191
192pub const TX_NOT_DUE: u16 = 2001;
196pub const TX_STALE_NEXT_RUN: u16 = 2002;
198pub const TX_SPEND_EXCEEDS_BUDGET: u16 = 2003;
200pub const TX_NOT_SCHEDULER: u16 = 2004;
202pub const TX_NOT_JOB_OWNER: u16 = 2005;
204pub const TX_UNKNOWN_JOB: u16 = 2006;
206pub const TX_JOB_NOT_ACTIVE: u16 = 2007;
208pub const TX_JOB_NOT_PAUSED: u16 = 2008;
210pub const TX_UNREGISTERED_TARGET: u16 = 2009;
212pub const TX_ZERO_INTERVAL: u16 = 2010;
214pub const TX_ZERO_RUNS: u16 = 2011;
216pub const TX_CODE_TAKEN: u16 = 2012;
218pub const TX_BAD_TTL: u16 = 2013;
220pub const TX_ESCROW_CAP_EXCEEDED: u16 = 2014;
222pub const TX_UNKNOWN_INVITE: u16 = 2015;
224pub const TX_NOT_OPEN: u16 = 2016;
226pub const TX_EXPIRED: u16 = 2017;
228pub const TX_NOT_YET_EXPIRED: u16 = 2018;
230pub const TX_ZERO_BUDGET: u16 = 2019;
232pub const TX_ZERO_AMOUNT: u16 = 2020;
234pub const TX_NOT_CONFIGURED: u16 = 2021;
236pub const TX_REASON_STRING: u16 = 2022;
238pub const TX_PANIC: u16 = 2023;
240pub const TX_INSUFFICIENT_CREDITS: u16 = 2024;
244
245pub const BACKEND_RATE_LIMIT: u16 = 3001;
251pub const BACKEND_AUTH: u16 = 3002;
254pub const BACKEND_CREDITS: u16 = 3003;
256pub const BACKEND_TIMEOUT: u16 = 3004;
258pub const BACKEND_EMPTY: u16 = 3005;
260pub const BACKEND_SERVER: u16 = 3006;
262pub const BACKEND_NETWORK: u16 = 3007;
264pub const BACKEND_STALE_AUTH: u16 = 3008;
267pub const BACKEND_SEND: u16 = 3009;
273
274pub const CORE_IO: u16 = 4001;
280pub const CORE_JSON: u16 = 4002;
282pub const CORE_HTTP: u16 = 4003;
285pub const CORE_CLOSED: u16 = 4004;
287pub const CORE_NOT_STARTED: u16 = 4005;
289pub const CORE_ALREADY_STARTED: u16 = 4006;
291pub const CORE_CONFIG: u16 = 4007;
293pub const CORE_TOOL_NOT_FOUND: u16 = 4008;
295pub const CORE_TOOL_FAILED: u16 = 4009;
300pub const CORE_POLICY_DENIED: u16 = 4010;
302pub const CORE_TIMEOUT: u16 = 4011;
304pub const CORE_OTHER: u16 = 4012;
307pub const CORE_DECODE: u16 = 4013;
312
313pub const REGISTRY: &[ErrorCode] = &[
318 ec(UNEXPECTED_BYTE, Family::Compile, "unexpected byte in source",
320 "remove the stray character; rustlite only accepts ASCII Rust-subset source"),
321 ec(UNTERMINATED_STRING, Family::Compile, "unterminated string literal",
322 "add the closing \" on the same line (strings can't span newlines)"),
323 ec(UNKNOWN_ESCAPE, Family::Compile, "unknown string/char escape",
324 "use a supported escape: \\n \\t \\\\ \\\" \\0"),
325 ec(BAD_CHAR_LITERAL, Family::Compile, "malformed char literal",
326 "a 'x' char is exactly one byte; use a \"string\" for text"),
327 ec(BAD_NUMBER, Family::Compile, "malformed numeric literal",
328 "check the digits/suffix; hex is 0xFF, floats need a fractional digit"),
329 ec(UNEXPECTED_TOKEN, Family::Compile, "unexpected token",
331 "the grammar expected a different token here — read the [start..end] span"),
332 ec(EXPECTED_ITEM, Family::Compile, "expected a top-level item",
333 "only fn/struct/enum/const are allowed at the top level"),
334 ec(EXPECTED_TYPE, Family::Compile, "expected a type",
335 "supply a known type (i32/i64/f32/f64/bool or a declared struct/enum)"),
336 ec(EXPECTED_EXPRESSION, Family::Compile, "expected an expression",
337 "an expression is required here; check for a dangling operator"),
338 ec(EXPECTED_PATTERN, Family::Compile, "expected a pattern",
339 "a match arm / let needs a pattern (binding, literal, path, or range)"),
340 ec(MISSING_SEMICOLON, Family::Compile, "missing ';' after a statement",
341 "terminate the statement with ';' (or close the block with '}')"),
342 ec(INVALID_ASSIGN_TARGET, Family::Compile, "invalid assignment target",
343 "assign to a variable, struct field, or arr[i]; non-places (5 = 9) and indexed writes through struct fields (s.arr[i] = v) are unsupported"),
344 ec(NESTING_TOO_DEEP, Family::Compile, "nesting too deep",
345 "flatten deeply-nested expressions/blocks; the parser caps recursion depth"),
346 ec(UNKNOWN_TYPE, Family::Compile, "unknown type name",
348 "declare the struct/enum, or use a primitive (i32/i64/f32/f64/bool)"),
349 ec(UNDEFINED_VARIABLE, Family::Compile, "undefined variable",
350 "declare it with let before use, or fix the spelling"),
351 ec(UNKNOWN_FUNCTION, Family::Compile, "unknown function",
352 "define the fn, or use a valid host fn (host::display::*, host::net::*, …)"),
353 ec(ARITY_MISMATCH, Family::Compile, "wrong number of arguments",
354 "match the function's parameter count exactly"),
355 ec(TYPE_MISMATCH, Family::Compile, "type mismatch",
356 "convert with an `as` cast or fix the operand types so they agree"),
357 ec(NOT_MUTABLE, Family::Compile, "assignment to a non-mut binding",
358 "declare it `let mut` to reassign"),
359 ec(BAD_FIELD_ACCESS, Family::Compile, "field access on a non-struct / missing field",
360 "access a real field of a struct value"),
361 ec(BAD_INDEX, Family::Compile, "invalid index expression",
362 "index an array with an i32; only arrays of i32 are indexable"),
363 ec(BAD_CAST, Family::Compile, "invalid `as` cast",
364 "`as` only converts between numbers (i32/i64/f32/f64)"),
365 ec(UNKNOWN_STRUCT, Family::Compile, "unknown struct in a literal",
366 "declare the struct before constructing it"),
367 ec(UNSUPPORTED_FEATURE, Family::Compile, "unsupported language feature",
369 "rustlite lacks traits/generics/references/heap types (Vec/String/Box)/globals"),
370 ec(UNKNOWN_HOST_IMPORT, Family::Compile, "unknown host import",
371 "use a registered host fn — check the host::display / host::net / host::audio names + arity"),
372 ec(NO_ENTRY, Family::Compile, "no frame/render entry export",
373 "add `fn frame(t: i32)` (animated) or `fn render()` (one-shot) — the loader calls one of these"),
374 ec(OVERSIZE, Family::Compile, "cartridge exceeds the publish size cap",
375 "shrink the cartridge below the on-chain publish cap before publishing"),
376 ec(FRAME_TIMEOUT, Family::Runtime, "cartridge hung (watchdog terminated it)",
378 "a frame() ran too long / looped unbounded — bound your loops; reload to retry"),
379 ec(WASM_TRAP, Family::Runtime, "cartridge trapped during a frame",
380 "a wasm trap (unreachable / out-of-bounds) — check array indices + arithmetic"),
381 ec(INSTANTIATE_FAILED, Family::Runtime, "cartridge failed to instantiate",
382 "the wasm module is invalid/incompatible — recompile with compile_rustlite"),
383 ec(NO_ENTRY_RUNTIME, Family::Runtime, "cartridge exports neither frame nor render",
384 "export `fn frame(t: i32)` or `fn render()` so the engine has an entry to call"),
385 ec(TX_NOT_DUE, Family::TxRevert, "NotDue — job not due yet",
387 "the scheduler only fires on the interval; check `localharness jobs`"),
388 ec(TX_STALE_NEXT_RUN, Family::TxRevert, "StaleNextRun — run already fired",
389 "the on-chain clock already advanced; nothing to do"),
390 ec(TX_SPEND_EXCEEDS_BUDGET, Family::TxRevert, "SpendExceedsBudget — over the job budget",
391 "top up the job or it will be marked exhausted"),
392 ec(TX_NOT_SCHEDULER, Family::TxRevert, "NotScheduler — scheduler-only call",
393 "only the scheduler worker can record a run; not a user action"),
394 ec(TX_NOT_JOB_OWNER, Family::TxRevert, "NotJobOwner — you don't own this job",
395 "use the right `--as` identity; check `localharness jobs`"),
396 ec(TX_UNKNOWN_JOB, Family::TxRevert, "UnknownJob — no job with that id",
397 "list yours with `localharness jobs` (the id is the #N)"),
398 ec(TX_JOB_NOT_ACTIVE, Family::TxRevert, "JobNotActive — already cancelled/exhausted",
399 "nothing to cancel; see `localharness jobs`"),
400 ec(TX_JOB_NOT_PAUSED, Family::TxRevert, "JobNotPaused — can't resume a running job",
401 "only a paused job can be resumed"),
402 ec(TX_UNREGISTERED_TARGET, Family::TxRevert, "UnregisteredTarget — target isn't an agent",
403 "confirm it exists first (`localharness whoami <target>`)"),
404 ec(TX_ZERO_INTERVAL, Family::TxRevert, "ZeroInterval — interval below the 60s minimum",
405 "use `--every 60s` or more"),
406 ec(TX_ZERO_RUNS, Family::TxRevert, "ZeroRuns — max-runs must be >= 1",
407 "drop `--runs 0`"),
408 ec(TX_CODE_TAKEN, Family::TxRevert, "CodeTaken — invite code already exists",
409 "generate a fresh code (`invite create` makes a new one each time)"),
410 ec(TX_BAD_TTL, Family::TxRevert, "BadTtl — TTL outside 1h..90d",
411 "use e.g. `--ttl 7d`"),
412 ec(TX_ESCROW_CAP_EXCEEDED, Family::TxRevert, "EscrowCapExceeded — past the per-funder cap",
413 "reclaim an expired invite or use a smaller amount"),
414 ec(TX_UNKNOWN_INVITE, Family::TxRevert, "UnknownInvite — no invite for that code",
415 "double-check you copied the full code (incl. the inv- prefix)"),
416 ec(TX_NOT_OPEN, Family::TxRevert, "NotOpen — invite already accepted/reclaimed",
417 "it's spent; ask for a fresh invite"),
418 ec(TX_EXPIRED, Family::TxRevert, "Expired — invite past its TTL",
419 "it can only be reclaimed by its funder now (`invite reclaim <code>`)"),
420 ec(TX_NOT_YET_EXPIRED, Family::TxRevert, "NotYetExpired — reclaim only after the TTL",
421 "until then it can still be accepted"),
422 ec(TX_ZERO_BUDGET, Family::TxRevert, "ZeroBudget — budget must be > 0",
423 "supply a positive budget"),
424 ec(TX_ZERO_AMOUNT, Family::TxRevert, "ZeroAmount — amount must be > 0",
425 "supply a positive amount"),
426 ec(TX_NOT_CONFIGURED, Family::TxRevert, "NotConfigured — credits token unset",
427 "a platform-side misconfiguration; report it via `localharness feedback`"),
428 ec(TX_REASON_STRING, Family::TxRevert, "Error(string) — reverted with a reason",
429 "the decoded reason is shown inline; an escrow/balance reason means you need more $LH"),
430 ec(TX_PANIC, Family::TxRevert, "Panic — internal assertion failed",
431 "a platform bug, not your input; please `localharness feedback` it"),
432 ec(TX_INSUFFICIENT_CREDITS, Family::TxRevert, "InsufficientCredits — chat-meter credits locked or short",
433 "fiat-minted $LH is locked for spending on inference, not withdraw/transfer; check_balances shows the withdrawable amount + unlock time"),
434 ec(BACKEND_RATE_LIMIT, Family::Backend, "model provider rate-limited / over quota",
436 "the platform's model provider is throttled or over its spend cap — wait a moment and retry; not a problem with your account"),
437 ec(BACKEND_AUTH, Family::Backend, "model API key rejected",
438 "check the Gemini/model API key (BYOK); on the platform path this is a server-side key issue to report"),
439 ec(BACKEND_CREDITS, Family::Backend, "out of platform credits ($LH)",
440 "redeem a code or top up — this signing address has no active session / no $LH"),
441 ec(BACKEND_TIMEOUT, Family::Backend, "the model request timed out",
442 "the backend didn't respond in time — retry; if it persists the provider may be degraded"),
443 ec(BACKEND_EMPTY, Family::Backend, "empty or truncated model response",
444 "the model returned nothing usable — retry; shortening the input can help"),
445 ec(BACKEND_SERVER, Family::Backend, "model backend error (5xx)",
446 "the provider returned a server error — transient; retry shortly"),
447 ec(BACKEND_NETWORK, Family::Backend, "network / transport failure",
448 "couldn't reach the backend or proxy — check connectivity and retry"),
449 ec(BACKEND_STALE_AUTH, Family::Backend, "request auth went stale (device clock skew)",
450 "your device clock is off by more than ~5 minutes — sync it and retry"),
451 ec(BACKEND_SEND, Family::Backend, "request POST failed in transit (no response)",
452 "the network dropped the request before a response arrived — usually a flaky connection; retry"),
453 ec(CORE_IO, Family::Core, "I/O error",
455 "an OS-level read/write failed — check paths and permissions"),
456 ec(CORE_JSON, Family::Core, "JSON (de)serialization error",
457 "malformed or unexpected JSON — verify the payload shape"),
458 ec(CORE_HTTP, Family::Core, "HTTP transport error",
459 "the request failed at the transport layer — retry; check the endpoint"),
460 ec(CORE_CLOSED, Family::Core, "connection closed unexpectedly",
461 "the stream/connection dropped — restart the operation"),
462 ec(CORE_NOT_STARTED, Family::Core, "agent not started",
463 "call start() before using the agent"),
464 ec(CORE_ALREADY_STARTED, Family::Core, "agent already started",
465 "start() was called more than once — reuse the running agent"),
466 ec(CORE_CONFIG, Family::Core, "invalid configuration",
467 "fix the configuration value named in the message"),
468 ec(CORE_TOOL_NOT_FOUND, Family::Core, "tool not found",
469 "no tool is registered under that name — register it or fix the name"),
470 ec(CORE_TOOL_FAILED, Family::Core, "tool execution failed",
471 "the tool returned an error — see the inline message for the cause"),
472 ec(CORE_POLICY_DENIED, Family::Core, "policy denied the operation",
473 "a policy blocked this action — adjust the request or the policy"),
474 ec(CORE_TIMEOUT, Family::Core, "operation timed out",
475 "the operation exceeded its deadline — raise the timeout or retry"),
476 ec(CORE_OTHER, Family::Core, "unspecified error",
477 "a catch-all error — see the inline message for details"),
478 ec(CORE_DECODE, Family::Core, "payload decode error",
479 "the bytes didn't match the expected shape — the message names the codec boundary"),
480];
481
482const fn ec(code: u16, family: Family, meaning: &'static str, hint: &'static str) -> ErrorCode {
484 ErrorCode { code, family, meaning, hint }
485}
486
487pub fn lookup(code: u16) -> Option<&'static ErrorCode> {
489 REGISTRY.iter().find(|e| e.code == code)
490}
491
492pub fn runtime_phase(code: u16) -> &'static str {
498 match code {
499 INSTANTIATE_FAILED | NO_ENTRY_RUNTIME => "instantiate",
500 _ => "run",
501 }
502}
503
504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
517pub struct AuthFailureCopy {
518 pub line: &'static str,
520 pub status: &'static str,
522 pub prompt_for_key: bool,
524 pub show_raw: bool,
526}
527
528pub const fn auth_failure_copy(byok: bool) -> AuthFailureCopy {
531 if byok {
532 AuthFailureCopy {
533 line: "model rejected the API key — check your Gemini key",
534 status: "API key rejected — check your Gemini key.",
535 prompt_for_key: true,
536 show_raw: true,
537 }
538 } else {
539 AuthFailureCopy {
540 line: "the platform's model key was rejected upstream — a server-side problem \
541 on our end, not your $LH and not a key of yours. it is reported \
542 automatically; retry in a moment.",
543 status: "platform model key rejected — server-side, not your $LH",
544 prompt_for_key: false,
545 show_raw: false,
546 }
547 }
548}
549
550pub fn describe(code: u16) -> String {
552 match lookup(code) {
553 Some(e) => format!("{}: {}", e.label(), e.meaning),
554 None => fmt_label(code),
555 }
556}
557
558pub fn compact_index() -> String {
562 let mut out = String::new();
563 for fam in FAMILIES {
564 out.push_str(fam.label());
565 out.push_str(":\n");
566 for e in REGISTRY.iter().filter(|e| e.family == fam) {
567 out.push_str(&format!(" {} {}\n", e.label(), e.meaning));
568 }
569 }
570 out.trim_end().to_string()
571}
572
573pub fn classify_status(status: u16) -> Option<u16> {
578 match status {
579 429 => Some(BACKEND_RATE_LIMIT),
580 401 | 403 => Some(BACKEND_AUTH),
581 402 => Some(BACKEND_CREDITS),
582 408 => Some(BACKEND_TIMEOUT),
583 500..=599 => Some(BACKEND_SERVER),
584 _ => None,
585 }
586}
587
588pub fn classify_http(status: u16, body: &str) -> Option<u16> {
596 let l = body.to_lowercase();
597 if l.contains("stale or future timestamp") || l.contains("clock") {
598 return Some(BACKEND_STALE_AUTH);
599 }
600 classify_status(status).or_else(|| classify(body))
601}
602
603pub fn classify(s: &str) -> Option<u16> {
614 let l = s.to_lowercase();
615 if l.contains("stale or future timestamp") || l.contains("clock") {
618 return Some(BACKEND_STALE_AUTH);
619 }
620 if l.contains("429")
624 || l.contains("rate limit")
625 || l.contains("rate-limit")
626 || l.contains("resource_exhausted")
627 || l.contains("spending cap")
628 || l.contains("spend cap")
629 || l.contains("too many requests")
630 || l.contains("quota")
631 || l.contains("overloaded")
632 {
633 return Some(BACKEND_RATE_LIMIT);
634 }
635 if l.contains("401")
636 || l.contains("403")
637 || l.contains("api key")
638 || l.contains("api_key")
639 || l.contains("permission_denied")
640 || l.contains("unauthenticated")
641 || l.contains("unauthorized")
642 {
643 return Some(BACKEND_AUTH);
644 }
645 if l.contains("402")
646 || l.contains("payment required")
647 || l.contains("no $lh")
648 || l.contains("no credit")
649 || (l.contains("insufficient")
650 && (l.contains("credit")
651 || l.contains("balance")
652 || l.contains("funds")
653 || l.contains("$lh")))
654 || l.contains("no active session")
655 {
656 return Some(BACKEND_CREDITS);
657 }
658 if l.contains("timed out") || l.contains("timeout") || l.contains("deadline") {
659 return Some(BACKEND_TIMEOUT);
660 }
661 if l.contains("empty response")
662 || l.contains("response truncated")
663 || l.contains("output truncated")
664 || l.contains("truncated response")
665 || l.contains("no response")
666 {
667 return Some(BACKEND_EMPTY);
668 }
669 if l.contains("500")
670 || l.contains("502")
671 || l.contains("503")
672 || l.contains("504")
673 || l.contains("internal server")
674 {
675 return Some(BACKEND_SERVER);
676 }
677 if l.contains("network")
678 || l.contains("connection")
679 || l.contains("failed to fetch")
680 || l.contains("dns")
681 {
682 return Some(BACKEND_NETWORK);
683 }
684 if l.contains("error sending request") {
691 return Some(BACKEND_SEND);
692 }
693 None
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699
700 #[test]
701 fn codes_are_unique_and_in_family_range() {
702 let mut seen = std::collections::HashSet::new();
703 for e in REGISTRY {
704 assert!(seen.insert(e.code), "duplicate code LH{:04}", e.code);
705 assert_eq!(
706 Family::of(e.code),
707 Some(e.family),
708 "LH{:04} family {:?} doesn't match its numeric range",
709 e.code,
710 e.family
711 );
712 assert!(!e.meaning.is_empty() && !e.hint.is_empty(), "LH{:04} blank text", e.code);
714 }
715 }
716
717 #[test]
718 fn label_is_zero_padded() {
719 assert_eq!(fmt_label(1), "LH0001");
720 assert_eq!(fmt_label(204), "LH0204");
721 assert_eq!(fmt_label(2001), "LH2001");
722 assert_eq!(lookup(TYPE_MISMATCH).unwrap().label(), "LH0204");
723 }
724
725 #[test]
726 fn runtime_phase_maps_every_lh1xxx_code() {
727 assert_eq!(runtime_phase(INSTANTIATE_FAILED), "instantiate");
728 assert_eq!(runtime_phase(NO_ENTRY_RUNTIME), "instantiate");
729 assert_eq!(runtime_phase(WASM_TRAP), "run");
730 assert_eq!(runtime_phase(FRAME_TIMEOUT), "run");
731 for e in REGISTRY.iter().filter(|e| e.family == Family::Runtime) {
733 assert!(matches!(runtime_phase(e.code), "instantiate" | "run"));
734 }
735 }
736
737 #[test]
738 fn describe_falls_back_for_unknown() {
739 assert_eq!(describe(TYPE_MISMATCH), "LH0204: type mismatch");
740 assert_eq!(describe(9999), "LH9999");
741 }
742
743 #[test]
744 fn index_doc_lists_every_code() {
745 let doc = std::fs::read_to_string(concat!(
749 env!("CARGO_MANIFEST_DIR"),
750 "/docs/error-codes.md"
751 ))
752 .expect("docs/error-codes.md must exist");
753 for e in REGISTRY {
754 let label = e.label();
755 assert!(
756 doc.contains(&label),
757 "docs/error-codes.md is missing {label} ({})",
758 e.meaning
759 );
760 }
761 }
762
763 #[test]
764 fn compact_index_covers_all_families() {
765 let idx = compact_index();
766 for fam in FAMILIES {
767 assert!(idx.contains(&format!("{}:", fam.label())), "missing family {}", fam.label());
768 }
769 assert!(idx.contains("LH0204"));
770 assert!(idx.contains("LH1001"));
771 assert!(idx.contains("LH2003"));
772 assert!(idx.contains("LH3001"));
773 assert!(idx.contains("LH4001"));
774 }
775
776 #[test]
777 fn classify_maps_common_backend_errors() {
778 assert_eq!(classify("gemini HTTP 429 Too Many Requests"), Some(BACKEND_RATE_LIMIT));
779 assert_eq!(classify("status: RESOURCE_EXHAUSTED, spending cap"), Some(BACKEND_RATE_LIMIT));
780 assert_eq!(classify("exceeded your quota"), Some(BACKEND_RATE_LIMIT));
781 assert_eq!(classify("the model is overloaded"), Some(BACKEND_RATE_LIMIT));
782 assert_eq!(classify("HTTP 401 Unauthorized: bad API key"), Some(BACKEND_AUTH));
783 assert_eq!(classify("PERMISSION_DENIED"), Some(BACKEND_AUTH));
784 assert_eq!(classify("402 Payment Required: no $LH"), Some(BACKEND_CREDITS));
785 assert_eq!(classify("the request timed out"), Some(BACKEND_TIMEOUT));
786 assert_eq!(classify("empty response from model"), Some(BACKEND_EMPTY));
787 assert_eq!(classify("model output truncated at max_tokens"), Some(BACKEND_EMPTY));
788 assert_eq!(classify("the connection was truncated mid-stream"), Some(BACKEND_NETWORK));
789 assert_eq!(classify("HTTP 503 internal server error"), Some(BACKEND_SERVER));
790 assert_eq!(classify("failed to fetch: network down"), Some(BACKEND_NETWORK));
791 assert_eq!(classify("stale or future timestamp"), Some(BACKEND_STALE_AUTH));
792 assert_eq!(classify("a perfectly ordinary message"), None);
793 }
794
795 #[test]
800 fn classify_maps_bare_send_failure_to_backend_send() {
801 assert_eq!(classify("gemini POST: error sending request"), Some(BACKEND_SEND));
802 assert_eq!(classify("anthropic POST: error sending request"), Some(BACKEND_SEND));
803 assert_eq!(classify("openai POST: error sending request"), Some(BACKEND_SEND));
804 assert_eq!(
806 classify("error sending request: tcp connect error: Connection refused"),
807 Some(BACKEND_NETWORK)
808 );
809 assert_eq!(classify("error sending request: dns error"), Some(BACKEND_NETWORK));
810 assert_eq!(classify("error sending request: operation timed out"), Some(BACKEND_TIMEOUT));
812 }
813
814 #[test]
815 fn classify_prefers_rate_limit_over_credits() {
816 assert_eq!(
819 classify("429 RESOURCE_EXHAUSTED: project exceeded its monthly spending cap"),
820 Some(BACKEND_RATE_LIMIT)
821 );
822 }
823
824 #[test]
825 fn classify_status_reads_the_real_number() {
826 assert_eq!(classify_status(429), Some(BACKEND_RATE_LIMIT));
827 assert_eq!(classify_status(401), Some(BACKEND_AUTH));
828 assert_eq!(classify_status(403), Some(BACKEND_AUTH));
829 assert_eq!(classify_status(402), Some(BACKEND_CREDITS));
830 assert_eq!(classify_status(408), Some(BACKEND_TIMEOUT));
831 for s in [500, 502, 503, 504, 529] {
832 assert_eq!(classify_status(s), Some(BACKEND_SERVER), "status {s}");
833 }
834 assert_eq!(classify_status(400), None);
836 assert_eq!(classify_status(404), None);
837 assert_eq!(classify_status(200), None);
838 }
839
840 #[test]
841 fn classify_http_status_first_with_overrides_and_fallback() {
842 assert_eq!(classify_http(429, "<opaque provider body>"), Some(BACKEND_RATE_LIMIT));
844 assert_eq!(classify_http(503, "x"), Some(BACKEND_SERVER));
845 assert_eq!(classify_http(401, "stale or future timestamp"), Some(BACKEND_STALE_AUTH));
847 assert_eq!(classify_http(400, "API key not valid"), Some(BACKEND_AUTH));
849 assert_eq!(classify_http(400, "exceeded your quota"), Some(BACKEND_RATE_LIMIT));
850 assert_eq!(classify_http(418, "a perfectly ordinary message"), None);
851 }
852
853 #[test]
854 fn lh3002_copy_never_blames_a_platform_user_for_a_key_they_dont_own() {
855 let platform = auth_failure_copy(false);
859 assert!(!platform.prompt_for_key, "platform users have no key to fix");
860 assert!(!platform.show_raw, "the raw body is a server-side blob");
861 assert!(!platform.line.contains("your Gemini key"));
862 assert!(!platform.status.contains("your Gemini key"));
863 assert!(platform.line.contains("$LH"));
865 assert!(platform.line.contains("server-side"));
866
867 let byok = auth_failure_copy(true);
869 assert!(byok.prompt_for_key);
870 assert!(byok.show_raw);
871 assert!(byok.line.contains("Gemini key"));
872 }
873
874 #[test]
875 fn classify_narrows_bare_insufficient() {
876 assert_ne!(classify("insufficient storage"), Some(BACKEND_CREDITS));
879 assert_eq!(classify("insufficient credit balance"), Some(BACKEND_CREDITS));
881 assert_eq!(classify("402 payment required"), Some(BACKEND_CREDITS));
882 assert_eq!(classify("insufficient quota"), Some(BACKEND_RATE_LIMIT));
884 }
885}