1use std::fmt;
21
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23use serde_json::Number;
24
25pub const INVALID_PARAMS: &str = "invalid_params";
30
31pub const BATCH_ID_REUSE: &str = "batch_id_reuse";
34
35pub const MODEL_UNKNOWN: &str = "model_unknown";
38
39pub const MODEL_NOT_CLASSIFIER: &str = "model_not_classifier";
42
43pub const MODEL_NOT_EXTRACTOR: &str = "model_not_extractor";
47
48pub const COST_EXCEEDED: &str = "cost_exceeded";
53
54pub const ACCOUNT_WALLED: &str = "account_walled";
57
58pub const BATCH_IN_PROGRESS: &str = "batch_in_progress";
61
62pub const AUTH_FAILED: &str = "auth_failed";
69
70pub const MODEL_UNAVAILABLE: &str = "model_unavailable";
77
78pub const ACCOUNT_LIMIT_EXHAUSTED: &str = "account_limit_exhausted";
85
86pub const ACCOUNT_NOT_ENTITLED: &str = "account_not_entitled";
93
94pub const RATE_LIMITED: &str = "rate_limited";
101
102pub const PROVIDER_ERROR: &str = "provider_error";
105
106pub const PROVIDER_REFUSED: &str = "provider_refused";
108
109pub const INVALID_ITEM: &str = "invalid_item";
112
113pub const ANSWER_TOO_LARGE: &str = "answer_too_large";
120
121pub const BATCH_LEVEL_CODES: &[&str] = &[ACCOUNT_LIMIT_EXHAUSTED, ACCOUNT_NOT_ENTITLED];
125
126pub const ADMISSION_VOCABULARY: &[&str] = &[
129 INVALID_PARAMS,
130 BATCH_ID_REUSE,
131 MODEL_UNKNOWN,
132 MODEL_NOT_CLASSIFIER,
133 MODEL_NOT_EXTRACTOR,
134 COST_EXCEEDED,
135 ACCOUNT_WALLED,
136 BATCH_IN_PROGRESS,
137 AUTH_FAILED,
138 MODEL_UNAVAILABLE,
139 ACCOUNT_LIMIT_EXHAUSTED,
140 ACCOUNT_NOT_ENTITLED,
141];
142
143pub const ITEM_VOCABULARY: &[&str] = &[
146 RATE_LIMITED,
147 PROVIDER_ERROR,
148 PROVIDER_REFUSED,
149 INVALID_ITEM,
150 COST_EXCEEDED,
151 AUTH_FAILED,
152 MODEL_UNAVAILABLE,
153 ACCOUNT_LIMIT_EXHAUSTED,
154 ACCOUNT_NOT_ENTITLED,
155 ANSWER_TOO_LARGE,
156];
157
158pub fn admission_class(code: &str) -> Option<ErrorClass> {
161 match code {
162 INVALID_PARAMS | BATCH_ID_REUSE | MODEL_UNKNOWN | MODEL_NOT_CLASSIFIER
163 | MODEL_NOT_EXTRACTOR | COST_EXCEEDED | AUTH_FAILED | MODEL_UNAVAILABLE
164 | ACCOUNT_NOT_ENTITLED => Some(ErrorClass::Permanent),
165 ACCOUNT_WALLED | BATCH_IN_PROGRESS | ACCOUNT_LIMIT_EXHAUSTED => Some(ErrorClass::Transient),
166 _ => None,
167 }
168}
169
170pub fn item_class(code: &str) -> Option<ErrorClass> {
173 match code {
174 RATE_LIMITED | PROVIDER_ERROR | ACCOUNT_LIMIT_EXHAUSTED => Some(ErrorClass::Transient),
175 PROVIDER_REFUSED | INVALID_ITEM | COST_EXCEEDED | AUTH_FAILED | MODEL_UNAVAILABLE
176 | ACCOUNT_NOT_ENTITLED | ANSWER_TOO_LARGE => Some(ErrorClass::Permanent),
177 _ => None,
178 }
179}
180
181pub mod classes {
186 pub const TRANSIENT: &str = "transient";
188 pub const PERMANENT: &str = "permanent";
190}
191
192#[derive(Clone, Debug, PartialEq, Eq, Hash)]
196#[non_exhaustive]
197pub enum ErrorClass {
198 Transient,
199 Permanent,
200 Other(String),
201}
202
203impl ErrorClass {
204 pub fn parse(class: &str) -> Self {
205 match class {
206 classes::TRANSIENT => Self::Transient,
207 classes::PERMANENT => Self::Permanent,
208 other => Self::Other(other.to_owned()),
209 }
210 }
211
212 pub fn as_str(&self) -> &str {
213 match self {
214 Self::Transient => classes::TRANSIENT,
215 Self::Permanent => classes::PERMANENT,
216 Self::Other(other) => other,
217 }
218 }
219
220 pub fn effective(&self) -> Self {
223 match self {
224 Self::Transient => Self::Transient,
225 Self::Permanent | Self::Other(_) => Self::Permanent,
226 }
227 }
228
229 pub fn is_transient(&self) -> bool {
231 self.effective() == Self::Transient
232 }
233}
234
235impl fmt::Display for ErrorClass {
236 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237 f.write_str(self.as_str())
238 }
239}
240
241impl Serialize for ErrorClass {
242 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
243 serializer.serialize_str(self.as_str())
244 }
245}
246
247impl<'de> Deserialize<'de> for ErrorClass {
248 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
249 Ok(Self::parse(&String::deserialize(deserializer)?))
250 }
251}
252
253#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
259#[non_exhaustive]
260pub struct ItemErrorDetail {
261 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub field: Option<String>,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub limit: Option<u64>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub size: Option<u64>,
270}
271
272#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
279#[non_exhaustive]
280pub struct ItemError {
281 pub code: String,
284 pub class: ErrorClass,
285 pub message: String,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub retry_after_ms: Option<u64>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub detail: Option<ItemErrorDetail>,
292}
293
294impl ItemError {
295 pub fn new(code: impl Into<String>, class: ErrorClass, message: impl Into<String>) -> Self {
296 Self {
297 code: code.into(),
298 class,
299 message: message.into(),
300 retry_after_ms: None,
301 detail: None,
302 }
303 }
304
305 pub fn for_code(code: &str, message: impl Into<String>) -> Self {
308 Self::new(
309 code,
310 item_class(code).unwrap_or(ErrorClass::Permanent),
311 message,
312 )
313 }
314
315 pub fn answer_too_large(field: impl Into<String>, limit: u64, size: u64) -> Self {
318 let field = field.into();
319 let mut error = Self::for_code(
320 ANSWER_TOO_LARGE,
321 format!("the answer to {field} is {size} bytes; at most {limit}"),
322 );
323 error.detail = Some(ItemErrorDetail {
324 field: Some(field),
325 limit: Some(limit),
326 size: Some(size),
327 });
328 error
329 }
330
331 pub fn with_retry_after_ms(mut self, retry_after_ms: u64) -> Self {
332 self.retry_after_ms = Some(retry_after_ms);
333 self
334 }
335
336 pub fn with_detail(mut self, detail: ItemErrorDetail) -> Self {
337 self.detail = Some(detail);
338 self
339 }
340
341 pub fn is_retried_on_resend(&self) -> bool {
347 self.class.is_transient()
348 }
349
350 pub fn is_stored(&self) -> bool {
363 !is_batch_level(&self.code)
364 }
365
366 pub fn stops_the_call(&self) -> bool {
371 is_batch_level(&self.code) || self.code == RATE_LIMITED
372 }
373}
374
375pub fn is_batch_level(code: &str) -> bool {
379 matches!(
380 code,
381 AUTH_FAILED | MODEL_UNAVAILABLE | ACCOUNT_LIMIT_EXHAUSTED | ACCOUNT_NOT_ENTITLED
382 )
383}
384
385#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
391#[non_exhaustive]
392pub struct RefusalDetail {
393 pub class: ErrorClass,
394 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub field: Option<String>,
401 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub limit: Option<u64>,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub model: Option<String>,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub kind: Option<String>,
410 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub estimate_usd: Option<Number>,
412 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub max_cost_usd: Option<Number>,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub resets_at_ms: Option<u64>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub retry_after_ms: Option<u64>,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub credential: Option<String>,
423}
424
425impl RefusalDetail {
426 pub fn new(class: ErrorClass) -> Self {
427 Self {
428 class,
429 field: None,
430 limit: None,
431 model: None,
432 kind: None,
433 estimate_usd: None,
434 max_cost_usd: None,
435 resets_at_ms: None,
436 retry_after_ms: None,
437 credential: None,
438 }
439 }
440
441 pub fn with_field(mut self, field: impl Into<String>) -> Self {
442 self.field = Some(field.into());
443 self
444 }
445
446 pub fn with_limit(mut self, limit: u64) -> Self {
447 self.limit = Some(limit);
448 self
449 }
450
451 pub fn with_model(mut self, model: impl Into<String>) -> Self {
452 self.model = Some(model.into());
453 self
454 }
455
456 pub fn with_kind(mut self, kind: impl Into<String>) -> Self {
457 self.kind = Some(kind.into());
458 self
459 }
460
461 pub fn with_estimate_usd(mut self, estimate_usd: Number) -> Self {
462 self.estimate_usd = Some(estimate_usd);
463 self
464 }
465
466 pub fn with_max_cost_usd(mut self, max_cost_usd: Number) -> Self {
467 self.max_cost_usd = Some(max_cost_usd);
468 self
469 }
470
471 pub fn with_resets_at_ms(mut self, resets_at_ms: u64) -> Self {
472 self.resets_at_ms = Some(resets_at_ms);
473 self
474 }
475
476 pub fn with_retry_after_ms(mut self, retry_after_ms: u64) -> Self {
477 self.retry_after_ms = Some(retry_after_ms);
478 self
479 }
480
481 pub fn with_credential(mut self, credential: impl Into<String>) -> Self {
482 self.credential = Some(credential.into());
483 self
484 }
485}
486
487#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
491#[non_exhaustive]
492pub struct Refusal {
493 pub code: String,
496 pub message: String,
497 pub detail: Box<RefusalDetail>,
499}
500
501impl Refusal {
502 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
505 let code = code.into();
506 let class = admission_class(&code).unwrap_or(ErrorClass::Permanent);
507 Self {
508 code,
509 message: message.into(),
510 detail: Box::new(RefusalDetail::new(class)),
511 }
512 }
513
514 pub fn with_detail(mut self, detail: RefusalDetail) -> Self {
515 self.detail = Box::new(detail);
516 self
517 }
518
519 fn edit(mut self, edit: impl FnOnce(RefusalDetail) -> RefusalDetail) -> Self {
520 let detail =
521 std::mem::replace(&mut *self.detail, RefusalDetail::new(ErrorClass::Permanent));
522 *self.detail = edit(detail);
523 self
524 }
525
526 pub fn invalid_params(field: impl Into<String>, message: impl Into<String>) -> Self {
528 Self::new(INVALID_PARAMS, message).edit(|detail| detail.with_field(field))
529 }
530
531 pub fn over_limit(field: impl Into<String>, limit: u64, message: impl Into<String>) -> Self {
533 Self::invalid_params(field, message).edit(|detail| detail.with_limit(limit))
534 }
535
536 pub fn batch_id_reuse(field: impl Into<String>) -> Self {
538 let field = field.into();
539 Self::new(
540 BATCH_ID_REUSE,
541 format!("batch_id names a recorded batch whose {field} differs"),
542 )
543 .edit(|detail| detail.with_field(field))
544 }
545
546 pub fn model_unknown(model: impl Into<String>) -> Self {
547 let model = model.into();
548 Self::new(MODEL_UNKNOWN, format!("{model} is not in the catalog"))
549 .edit(|detail| detail.with_model(model))
550 }
551
552 pub fn model_not_classifier(model: impl Into<String>, kind: impl Into<String>) -> Self {
554 let (model, kind) = (model.into(), kind.into());
555 Self::new(
556 MODEL_NOT_CLASSIFIER,
557 format!("{model} is a {kind}, not a classifier"),
558 )
559 .edit(|detail| detail.with_model(model).with_kind(kind))
560 }
561
562 pub fn model_not_extractor(model: impl Into<String>, kind: Option<&str>) -> Self {
565 let model = model.into();
566 let message = match kind {
567 Some(kind) => format!("{model} is a {kind}, not an extraction model"),
568 None => {
569 format!("{model} states no kind in the catalog, so it is not an extraction model")
570 }
571 };
572 let refusal =
573 Self::new(MODEL_NOT_EXTRACTOR, message).edit(|detail| detail.with_model(model));
574 match kind {
575 Some(kind) => refusal.edit(|detail| detail.with_kind(kind)),
576 None => refusal,
577 }
578 }
579
580 pub fn cost_exceeded(estimate_usd: Number, max_cost_usd: Number) -> Self {
581 Self::new(
582 COST_EXCEEDED,
583 format!("estimated cost {estimate_usd} USD is over max_cost_usd {max_cost_usd}"),
584 )
585 .edit(|detail| {
586 detail
587 .with_estimate_usd(estimate_usd)
588 .with_max_cost_usd(max_cost_usd)
589 })
590 }
591
592 pub fn account_walled() -> Self {
593 Self::new(ACCOUNT_WALLED, "the provider account is at its limit")
594 }
595
596 pub fn auth_failed() -> Self {
598 Self::new(AUTH_FAILED, "the provider refused the module's credentials")
599 }
600
601 pub fn model_unavailable(model: impl Into<String>) -> Self {
603 let model = model.into();
604 Self::new(
605 MODEL_UNAVAILABLE,
606 format!("the provider does not serve {model}"),
607 )
608 .edit(|detail| detail.with_model(model))
609 }
610
611 pub fn account_limit_exhausted() -> Self {
613 Self::new(
614 ACCOUNT_LIMIT_EXHAUSTED,
615 "the provider account used up its daily allocation",
616 )
617 }
618
619 pub fn account_not_entitled() -> Self {
622 Self::new(
623 ACCOUNT_NOT_ENTITLED,
624 "the provider account may not use this model",
625 )
626 }
627
628 pub fn batch_in_progress(retry_after_ms: u64) -> Self {
629 Self::new(BATCH_IN_PROGRESS, "another call holds this batch_id")
630 .edit(|detail| detail.with_retry_after_ms(retry_after_ms))
631 }
632
633 pub fn field(&self) -> Option<&str> {
635 self.detail.field.as_deref()
636 }
637}
638
639impl fmt::Display for Refusal {
640 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
641 match &self.detail.field {
642 Some(field) => write!(f, "{} ({field}): {}", self.code, self.message),
643 None => write!(f, "{}: {}", self.code, self.message),
644 }
645 }
646}
647
648impl std::error::Error for Refusal {}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653
654 #[test]
655 fn every_admission_code_has_a_class_and_the_account_limit_is_transient() {
656 for code in ADMISSION_VOCABULARY {
657 assert!(admission_class(code).is_some(), "{code}");
658 }
659 for code in ITEM_VOCABULARY {
660 assert!(item_class(code).is_some(), "{code}");
661 }
662 for code in [ACCOUNT_WALLED, BATCH_IN_PROGRESS, ACCOUNT_LIMIT_EXHAUSTED] {
663 assert_eq!(admission_class(code), Some(ErrorClass::Transient), "{code}");
664 }
665 assert_eq!(
666 admission_class(ACCOUNT_NOT_ENTITLED),
667 Some(ErrorClass::Permanent)
668 );
669 assert_eq!(admission_class("nope"), None);
670 }
671
672 #[test]
673 fn item_classes() {
674 for code in [RATE_LIMITED, PROVIDER_ERROR, ACCOUNT_LIMIT_EXHAUSTED] {
675 assert_eq!(item_class(code), Some(ErrorClass::Transient), "{code}");
676 }
677 for code in [
678 PROVIDER_REFUSED,
679 INVALID_ITEM,
680 COST_EXCEEDED,
681 AUTH_FAILED,
682 MODEL_UNAVAILABLE,
683 ACCOUNT_NOT_ENTITLED,
684 ANSWER_TOO_LARGE,
685 ] {
686 assert_eq!(item_class(code), Some(ErrorClass::Permanent), "{code}");
687 }
688 }
689
690 #[test]
691 fn batch_level_codes_are_never_stored_and_stop_the_call() {
692 for code in [
693 AUTH_FAILED,
694 MODEL_UNAVAILABLE,
695 ACCOUNT_LIMIT_EXHAUSTED,
696 ACCOUNT_NOT_ENTITLED,
697 ] {
698 let error = ItemError::for_code(code, "m");
699 assert!(!error.is_stored(), "{code}");
700 assert!(error.stops_the_call(), "{code}");
701 }
702 let limited = ItemError::for_code(RATE_LIMITED, "m");
703 assert!(limited.is_stored() && limited.stops_the_call());
704 for code in [
705 PROVIDER_ERROR,
706 PROVIDER_REFUSED,
707 INVALID_ITEM,
708 COST_EXCEEDED,
709 ANSWER_TOO_LARGE,
710 ] {
711 let error = ItemError::for_code(code, "m");
712 assert!(error.is_stored(), "{code}");
713 assert!(!error.stops_the_call(), "{code}");
714 }
715 }
716
717 #[test]
718 fn an_unknown_class_is_kept_and_acted_on_as_permanent() {
719 let error: ItemError = serde_json::from_str(
720 r#"{"code":"quota_gone","class":"degraded","message":"m","retry_after_ms":5}"#,
721 )
722 .unwrap();
723 assert_eq!(error.class, ErrorClass::Other("degraded".into()));
724 assert!(!error.is_retried_on_resend());
725 assert_eq!(
726 serde_json::to_string(&error).unwrap(),
727 r#"{"code":"quota_gone","class":"degraded","message":"m","retry_after_ms":5}"#
728 );
729 }
730
731 #[test]
732 fn answer_too_large_carries_field_limit_and_size() {
733 let error = ItemError::answer_too_large("fields.total", 4096, 5000);
734 assert_eq!(
735 serde_json::to_string(&error).unwrap(),
736 r#"{"code":"answer_too_large","class":"permanent","message":"the answer to fields.total is 5000 bytes; at most 4096","detail":{"field":"fields.total","limit":4096,"size":5000}}"#
737 );
738 assert!(error.is_stored());
739 assert!(!error.is_retried_on_resend());
740 }
741
742 #[test]
743 fn model_not_extractor_never_invents_a_kind() {
744 let kindless = Refusal::model_not_extractor("m", None);
745 assert_eq!(kindless.code, MODEL_NOT_EXTRACTOR);
746 assert_eq!(kindless.detail.model.as_deref(), Some("m"));
747 assert_eq!(kindless.detail.kind, None);
748 let chat = Refusal::model_not_extractor("m", Some("chat"));
749 assert_eq!(chat.detail.kind.as_deref(), Some("chat"));
750 assert_eq!(chat.detail.class, ErrorClass::Permanent);
751 }
752
753 #[test]
754 fn refusals_carry_their_class_and_field() {
755 let refusal = Refusal::over_limit("items", 100, "too many items");
756 assert_eq!(refusal.code, INVALID_PARAMS);
757 assert_eq!(refusal.field(), Some("items"));
758 assert_eq!(refusal.detail.limit, Some(100));
759 let busy = Refusal::batch_in_progress(250);
760 assert_eq!(busy.detail.class, ErrorClass::Transient);
761 assert_eq!(busy.detail.retry_after_ms, Some(250));
762 assert_eq!(
763 Refusal::account_limit_exhausted().detail.class,
764 ErrorClass::Transient
765 );
766 }
767}