Skip to main content

cortexkit_role_classifier/
errors.rs

1//! Error codes, their classes, and the two error shapes of `classify.run`.
2//!
3//! An admission refusal answers the whole request with an `ERROR` frame
4//! whose body is `{code, message, detail}` ([`Refusal`]); it writes nothing
5//! and dispatches nothing. After admission the batch never fails as a
6//! whole: an item that could not be answered carries an [`ItemError`] in
7//! its place in the reply.
8//!
9//! Codes are open strings on the wire: a code this crate does not list is
10//! kept as it was sent. Classes decode open too ([`ErrorClass::Other`]); a
11//! caller that meets an unknown class treats it as permanent
12//! ([`ErrorClass::effective`]), which is the safe direction.
13//!
14//! The vocabulary, its classes and both error shapes are the shared
15//! model-batch rules (`cortexkit-model-batch`, its `RULES.md` §14),
16//! re-exported here at their `classifier/v1` paths. What is this role's
17//! own is which codes it answers with: [`ADMISSION_CODES`] and
18//! [`ITEM_CODES`], the contract's §7 tables, and [`BATCH_LEVEL_CODES`],
19//! the Workers AI account codes its §8 maps clef's errors to.
20
21pub use cortexkit_model_batch::errors::{
22    admission_class, classes, is_batch_level, item_class, ErrorClass, ItemError, ItemErrorDetail,
23    Refusal, RefusalDetail, ACCOUNT_LIMIT_EXHAUSTED, ACCOUNT_NOT_ENTITLED, ACCOUNT_WALLED,
24    ANSWER_TOO_LARGE, AUTH_FAILED, BATCH_ID_REUSE, BATCH_IN_PROGRESS, BATCH_LEVEL_CODES,
25    COST_EXCEEDED, INVALID_ITEM, INVALID_PARAMS, MODEL_NOT_CLASSIFIER, MODEL_UNAVAILABLE,
26    MODEL_UNKNOWN, PROVIDER_ERROR, PROVIDER_REFUSED, RATE_LIMITED,
27};
28
29/// Every admission refusal code in the contract's §7 table, in its order.
30/// The batch-level account codes, which refuse a call only when its first
31/// provider call meets one, are [`BATCH_LEVEL_CODES`].
32pub const ADMISSION_CODES: &[&str] = &[
33    INVALID_PARAMS,
34    BATCH_ID_REUSE,
35    MODEL_UNKNOWN,
36    MODEL_NOT_CLASSIFIER,
37    COST_EXCEEDED,
38    ACCOUNT_WALLED,
39    BATCH_IN_PROGRESS,
40    AUTH_FAILED,
41    MODEL_UNAVAILABLE,
42];
43
44/// Every per-item error code in the contract's §7 table, in its order.
45/// `cost_exceeded`, `auth_failed` and `model_unavailable` are both
46/// admission refusals and per-item errors. The batch-level account codes
47/// are [`BATCH_LEVEL_CODES`]; `answer_too_large` is in the shared
48/// vocabulary, but `classifier/v1` states no answer bound and never answers
49/// with it.
50pub const ITEM_CODES: &[&str] = &[
51    RATE_LIMITED,
52    PROVIDER_ERROR,
53    PROVIDER_REFUSED,
54    INVALID_ITEM,
55    COST_EXCEEDED,
56    AUTH_FAILED,
57    MODEL_UNAVAILABLE,
58];
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn every_listed_code_has_its_tables_class() {
66        for code in ADMISSION_CODES {
67            assert!(admission_class(code).is_some(), "{code}");
68        }
69        for code in ITEM_CODES {
70            assert!(item_class(code).is_some(), "{code}");
71        }
72        assert_eq!(
73            admission_class(BATCH_IN_PROGRESS),
74            Some(ErrorClass::Transient)
75        );
76        assert_eq!(admission_class(ACCOUNT_WALLED), Some(ErrorClass::Transient));
77        assert_eq!(item_class(RATE_LIMITED), Some(ErrorClass::Transient));
78        assert_eq!(item_class(PROVIDER_ERROR), Some(ErrorClass::Transient));
79        assert_eq!(item_class(PROVIDER_REFUSED), Some(ErrorClass::Permanent));
80        assert_eq!(item_class(INVALID_ITEM), Some(ErrorClass::Permanent));
81        assert_eq!(item_class(COST_EXCEEDED), Some(ErrorClass::Permanent));
82        for code in [AUTH_FAILED, MODEL_UNAVAILABLE] {
83            assert_eq!(item_class(code), Some(ErrorClass::Permanent), "{code}");
84            assert_eq!(admission_class(code), Some(ErrorClass::Permanent), "{code}");
85        }
86        assert_eq!(admission_class("nope"), None);
87    }
88
89    #[test]
90    fn an_unknown_class_is_kept_and_acted_on_as_permanent() {
91        let error: ItemError = serde_json::from_str(
92            r#"{"code":"quota_gone","class":"degraded","message":"m","retry_after_ms":5}"#,
93        )
94        .unwrap();
95        assert_eq!(error.code, "quota_gone");
96        assert_eq!(error.class, ErrorClass::Other("degraded".into()));
97        assert_eq!(error.class.effective(), ErrorClass::Permanent);
98        assert!(!error.is_retried_on_resend());
99        assert_eq!(
100            serde_json::to_string(&error).unwrap(),
101            r#"{"code":"quota_gone","class":"degraded","message":"m","retry_after_ms":5}"#
102        );
103    }
104
105    #[test]
106    fn stored_transient_errors_are_retried_and_permanent_ones_are_not() {
107        for code in [RATE_LIMITED, PROVIDER_ERROR] {
108            assert!(
109                ItemError::for_code(code, "m").is_retried_on_resend(),
110                "{code}"
111            );
112        }
113        for code in [PROVIDER_REFUSED, INVALID_ITEM, COST_EXCEEDED] {
114            assert!(
115                !ItemError::for_code(code, "m").is_retried_on_resend(),
116                "{code}"
117            );
118        }
119    }
120
121    #[test]
122    fn auth_and_model_failures_are_never_stored_and_stop_the_call() {
123        for code in [AUTH_FAILED, MODEL_UNAVAILABLE] {
124            let error = ItemError::for_code(code, "m");
125            assert!(!error.is_stored(), "{code}");
126            assert!(error.stops_the_call(), "{code}");
127        }
128        assert!(ItemError::for_code(RATE_LIMITED, "m").stops_the_call());
129        for code in [
130            PROVIDER_ERROR,
131            PROVIDER_REFUSED,
132            INVALID_ITEM,
133            COST_EXCEEDED,
134        ] {
135            let error = ItemError::for_code(code, "m");
136            assert!(error.is_stored(), "{code}");
137            assert!(!error.stops_the_call(), "{code}");
138        }
139        assert!(ItemError::for_code(RATE_LIMITED, "m").is_stored());
140        assert_eq!(
141            Refusal::model_unavailable("m").detail.model.as_deref(),
142            Some("m")
143        );
144        assert_eq!(Refusal::auth_failed().detail.class, ErrorClass::Permanent);
145    }
146
147    #[test]
148    fn the_batch_level_account_codes_are_never_stored_and_stop_the_call() {
149        assert_eq!(
150            BATCH_LEVEL_CODES,
151            [ACCOUNT_LIMIT_EXHAUSTED, ACCOUNT_NOT_ENTITLED]
152        );
153        for code in BATCH_LEVEL_CODES {
154            let error = ItemError::for_code(code, "m");
155            assert!(!error.is_stored(), "{code}");
156            assert!(error.stops_the_call(), "{code}");
157            assert!(!ADMISSION_CODES.contains(code) && !ITEM_CODES.contains(code));
158        }
159        assert_eq!(
160            item_class(ACCOUNT_LIMIT_EXHAUSTED),
161            Some(ErrorClass::Transient)
162        );
163        assert_eq!(
164            item_class(ACCOUNT_NOT_ENTITLED),
165            Some(ErrorClass::Permanent)
166        );
167    }
168
169    #[test]
170    fn answer_too_large_is_in_the_vocabulary_but_not_the_tables() {
171        assert_eq!(item_class(ANSWER_TOO_LARGE), Some(ErrorClass::Permanent));
172        assert!(ItemError::for_code(ANSWER_TOO_LARGE, "m").is_stored());
173        assert!(!ITEM_CODES.contains(&ANSWER_TOO_LARGE));
174    }
175
176    #[test]
177    fn refusals_carry_their_class_and_field() {
178        let refusal = Refusal::over_limit("items", 100, "too many items");
179        assert_eq!(refusal.code, INVALID_PARAMS);
180        assert_eq!(refusal.field(), Some("items"));
181        assert_eq!(refusal.detail.limit, Some(100));
182        assert_eq!(refusal.detail.class, ErrorClass::Permanent);
183        let busy = Refusal::batch_in_progress(250);
184        assert_eq!(busy.detail.class, ErrorClass::Transient);
185        assert_eq!(busy.detail.retry_after_ms, Some(250));
186    }
187}