1use std::fmt;
15
16use serde::{Deserialize, Deserializer, Serialize, Serializer};
17use serde_json::Number;
18
19pub const INVALID_PARAMS: &str = "invalid_params";
24
25pub const BATCH_ID_REUSE: &str = "batch_id_reuse";
28
29pub const MODEL_UNKNOWN: &str = "model_unknown";
32
33pub const MODEL_NOT_CLASSIFIER: &str = "model_not_classifier";
36
37pub const COST_EXCEEDED: &str = "cost_exceeded";
42
43pub const ACCOUNT_WALLED: &str = "account_walled";
46
47pub const BATCH_IN_PROGRESS: &str = "batch_in_progress";
50
51pub const AUTH_FAILED: &str = "auth_failed";
58
59pub const MODEL_UNAVAILABLE: &str = "model_unavailable";
66
67pub const ADMISSION_CODES: &[&str] = &[
69 INVALID_PARAMS,
70 BATCH_ID_REUSE,
71 MODEL_UNKNOWN,
72 MODEL_NOT_CLASSIFIER,
73 COST_EXCEEDED,
74 ACCOUNT_WALLED,
75 BATCH_IN_PROGRESS,
76 AUTH_FAILED,
77 MODEL_UNAVAILABLE,
78];
79
80pub const RATE_LIMITED: &str = "rate_limited";
87
88pub const PROVIDER_ERROR: &str = "provider_error";
91
92pub const PROVIDER_REFUSED: &str = "provider_refused";
94
95pub const INVALID_ITEM: &str = "invalid_item";
98
99pub const ITEM_CODES: &[&str] = &[
103 RATE_LIMITED,
104 PROVIDER_ERROR,
105 PROVIDER_REFUSED,
106 INVALID_ITEM,
107 COST_EXCEEDED,
108 AUTH_FAILED,
109 MODEL_UNAVAILABLE,
110];
111
112pub fn admission_class(code: &str) -> Option<ErrorClass> {
115 match code {
116 INVALID_PARAMS | BATCH_ID_REUSE | MODEL_UNKNOWN | MODEL_NOT_CLASSIFIER | COST_EXCEEDED
117 | AUTH_FAILED | MODEL_UNAVAILABLE => Some(ErrorClass::Permanent),
118 ACCOUNT_WALLED | BATCH_IN_PROGRESS => Some(ErrorClass::Transient),
119 _ => None,
120 }
121}
122
123pub fn item_class(code: &str) -> Option<ErrorClass> {
126 match code {
127 RATE_LIMITED | PROVIDER_ERROR => Some(ErrorClass::Transient),
128 PROVIDER_REFUSED | INVALID_ITEM | COST_EXCEEDED | AUTH_FAILED | MODEL_UNAVAILABLE => {
129 Some(ErrorClass::Permanent)
130 }
131 _ => None,
132 }
133}
134
135pub mod classes {
140 pub const TRANSIENT: &str = "transient";
142 pub const PERMANENT: &str = "permanent";
144}
145
146#[derive(Clone, Debug, PartialEq, Eq, Hash)]
150#[non_exhaustive]
151pub enum ErrorClass {
152 Transient,
153 Permanent,
154 Other(String),
155}
156
157impl ErrorClass {
158 pub fn parse(class: &str) -> Self {
159 match class {
160 classes::TRANSIENT => Self::Transient,
161 classes::PERMANENT => Self::Permanent,
162 other => Self::Other(other.to_owned()),
163 }
164 }
165
166 pub fn as_str(&self) -> &str {
167 match self {
168 Self::Transient => classes::TRANSIENT,
169 Self::Permanent => classes::PERMANENT,
170 Self::Other(other) => other,
171 }
172 }
173
174 pub fn effective(&self) -> Self {
177 match self {
178 Self::Transient => Self::Transient,
179 Self::Permanent | Self::Other(_) => Self::Permanent,
180 }
181 }
182
183 pub fn is_transient(&self) -> bool {
185 self.effective() == Self::Transient
186 }
187}
188
189impl fmt::Display for ErrorClass {
190 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191 f.write_str(self.as_str())
192 }
193}
194
195impl Serialize for ErrorClass {
196 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
197 serializer.serialize_str(self.as_str())
198 }
199}
200
201impl<'de> Deserialize<'de> for ErrorClass {
202 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
203 Ok(Self::parse(&String::deserialize(deserializer)?))
204 }
205}
206
207#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
216#[non_exhaustive]
217pub struct ItemError {
218 pub code: String,
220 pub class: ErrorClass,
221 pub message: String,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub retry_after_ms: Option<u64>,
225}
226
227impl ItemError {
228 pub fn new(code: impl Into<String>, class: ErrorClass, message: impl Into<String>) -> Self {
229 Self {
230 code: code.into(),
231 class,
232 message: message.into(),
233 retry_after_ms: None,
234 }
235 }
236
237 pub fn for_code(code: &str, message: impl Into<String>) -> Self {
240 Self::new(
241 code,
242 item_class(code).unwrap_or(ErrorClass::Permanent),
243 message,
244 )
245 }
246
247 pub fn with_retry_after_ms(mut self, retry_after_ms: u64) -> Self {
248 self.retry_after_ms = Some(retry_after_ms);
249 self
250 }
251
252 pub fn is_retried_on_resend(&self) -> bool {
258 self.class.is_transient()
259 }
260
261 pub fn is_stored(&self) -> bool {
272 !matches!(self.code.as_str(), AUTH_FAILED | MODEL_UNAVAILABLE)
273 }
274
275 pub fn stops_the_call(&self) -> bool {
280 matches!(
281 self.code.as_str(),
282 AUTH_FAILED | MODEL_UNAVAILABLE | RATE_LIMITED
283 )
284 }
285}
286
287#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
293#[non_exhaustive]
294pub struct RefusalDetail {
295 pub class: ErrorClass,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub field: Option<String>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub limit: Option<u64>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub model: Option<String>,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub kind: Option<String>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub estimate_usd: Option<Number>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub max_cost_usd: Option<Number>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub resets_at_ms: Option<u64>,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub retry_after_ms: Option<u64>,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub credential: Option<String>,
327}
328
329impl RefusalDetail {
330 pub fn new(class: ErrorClass) -> Self {
331 Self {
332 class,
333 field: None,
334 limit: None,
335 model: None,
336 kind: None,
337 estimate_usd: None,
338 max_cost_usd: None,
339 resets_at_ms: None,
340 retry_after_ms: None,
341 credential: None,
342 }
343 }
344
345 pub fn with_field(mut self, field: impl Into<String>) -> Self {
346 self.field = Some(field.into());
347 self
348 }
349
350 pub fn with_limit(mut self, limit: u64) -> Self {
351 self.limit = Some(limit);
352 self
353 }
354
355 pub fn with_model(mut self, model: impl Into<String>) -> Self {
356 self.model = Some(model.into());
357 self
358 }
359
360 pub fn with_kind(mut self, kind: impl Into<String>) -> Self {
361 self.kind = Some(kind.into());
362 self
363 }
364
365 pub fn with_estimate_usd(mut self, estimate_usd: Number) -> Self {
366 self.estimate_usd = Some(estimate_usd);
367 self
368 }
369
370 pub fn with_max_cost_usd(mut self, max_cost_usd: Number) -> Self {
371 self.max_cost_usd = Some(max_cost_usd);
372 self
373 }
374
375 pub fn with_resets_at_ms(mut self, resets_at_ms: u64) -> Self {
376 self.resets_at_ms = Some(resets_at_ms);
377 self
378 }
379
380 pub fn with_retry_after_ms(mut self, retry_after_ms: u64) -> Self {
381 self.retry_after_ms = Some(retry_after_ms);
382 self
383 }
384
385 pub fn with_credential(mut self, credential: impl Into<String>) -> Self {
386 self.credential = Some(credential.into());
387 self
388 }
389}
390
391#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
395#[non_exhaustive]
396pub struct Refusal {
397 pub code: String,
400 pub message: String,
401 pub detail: Box<RefusalDetail>,
403}
404
405impl Refusal {
406 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
409 let code = code.into();
410 let class = admission_class(&code).unwrap_or(ErrorClass::Permanent);
411 Self {
412 code,
413 message: message.into(),
414 detail: Box::new(RefusalDetail::new(class)),
415 }
416 }
417
418 pub fn with_detail(mut self, detail: RefusalDetail) -> Self {
419 self.detail = Box::new(detail);
420 self
421 }
422
423 pub fn invalid_params(field: impl Into<String>, message: impl Into<String>) -> Self {
425 let refusal = Self::new(INVALID_PARAMS, message);
426 let detail = (*refusal.detail).clone().with_field(field);
427 refusal.with_detail(detail)
428 }
429
430 pub fn over_limit(field: impl Into<String>, limit: u64, message: impl Into<String>) -> Self {
432 let refusal = Self::invalid_params(field, message);
433 let detail = (*refusal.detail).clone().with_limit(limit);
434 refusal.with_detail(detail)
435 }
436
437 pub fn batch_id_reuse(field: impl Into<String>) -> Self {
439 let field = field.into();
440 let refusal = Self::new(
441 BATCH_ID_REUSE,
442 format!("batch_id names a recorded batch whose {field} differs"),
443 );
444 let detail = (*refusal.detail).clone().with_field(field);
445 refusal.with_detail(detail)
446 }
447
448 pub fn model_unknown(model: impl Into<String>) -> Self {
449 let model = model.into();
450 let refusal = Self::new(MODEL_UNKNOWN, format!("{model} is not in the catalog"));
451 let detail = (*refusal.detail).clone().with_model(model);
452 refusal.with_detail(detail)
453 }
454
455 pub fn model_not_classifier(model: impl Into<String>, kind: impl Into<String>) -> Self {
456 let (model, kind) = (model.into(), kind.into());
457 let refusal = Self::new(
458 MODEL_NOT_CLASSIFIER,
459 format!("{model} is a {kind}, not a classifier"),
460 );
461 let detail = (*refusal.detail).clone().with_model(model).with_kind(kind);
462 refusal.with_detail(detail)
463 }
464
465 pub fn cost_exceeded(estimate_usd: Number, max_cost_usd: Number) -> Self {
466 let refusal = Self::new(
467 COST_EXCEEDED,
468 format!("estimated cost {estimate_usd} USD is over max_cost_usd {max_cost_usd}"),
469 );
470 let detail = (*refusal.detail)
471 .clone()
472 .with_estimate_usd(estimate_usd)
473 .with_max_cost_usd(max_cost_usd);
474 refusal.with_detail(detail)
475 }
476
477 pub fn account_walled() -> Self {
478 Self::new(ACCOUNT_WALLED, "the provider account is at its limit")
479 }
480
481 pub fn auth_failed() -> Self {
483 Self::new(AUTH_FAILED, "the provider refused the module's credentials")
484 }
485
486 pub fn model_unavailable(model: impl Into<String>) -> Self {
488 let model = model.into();
489 let refusal = Self::new(
490 MODEL_UNAVAILABLE,
491 format!("the provider does not serve {model}"),
492 );
493 let detail = (*refusal.detail).clone().with_model(model);
494 refusal.with_detail(detail)
495 }
496
497 pub fn batch_in_progress(retry_after_ms: u64) -> Self {
498 let refusal = Self::new(BATCH_IN_PROGRESS, "another call holds this batch_id");
499 let detail = (*refusal.detail)
500 .clone()
501 .with_retry_after_ms(retry_after_ms);
502 refusal.with_detail(detail)
503 }
504
505 pub fn field(&self) -> Option<&str> {
507 self.detail.field.as_deref()
508 }
509}
510
511impl fmt::Display for Refusal {
512 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
513 match &self.detail.field {
514 Some(field) => write!(f, "{} ({field}): {}", self.code, self.message),
515 None => write!(f, "{}: {}", self.code, self.message),
516 }
517 }
518}
519
520impl std::error::Error for Refusal {}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525
526 #[test]
527 fn every_listed_code_has_its_tables_class() {
528 for code in ADMISSION_CODES {
529 assert!(admission_class(code).is_some(), "{code}");
530 }
531 for code in ITEM_CODES {
532 assert!(item_class(code).is_some(), "{code}");
533 }
534 assert_eq!(
535 admission_class(BATCH_IN_PROGRESS),
536 Some(ErrorClass::Transient)
537 );
538 assert_eq!(admission_class(ACCOUNT_WALLED), Some(ErrorClass::Transient));
539 assert_eq!(item_class(RATE_LIMITED), Some(ErrorClass::Transient));
540 assert_eq!(item_class(PROVIDER_ERROR), Some(ErrorClass::Transient));
541 assert_eq!(item_class(PROVIDER_REFUSED), Some(ErrorClass::Permanent));
542 assert_eq!(item_class(INVALID_ITEM), Some(ErrorClass::Permanent));
543 assert_eq!(item_class(COST_EXCEEDED), Some(ErrorClass::Permanent));
544 for code in [AUTH_FAILED, MODEL_UNAVAILABLE] {
545 assert_eq!(item_class(code), Some(ErrorClass::Permanent), "{code}");
546 assert_eq!(admission_class(code), Some(ErrorClass::Permanent), "{code}");
547 }
548 assert_eq!(admission_class("nope"), None);
549 }
550
551 #[test]
552 fn an_unknown_class_is_kept_and_acted_on_as_permanent() {
553 let error: ItemError = serde_json::from_str(
554 r#"{"code":"quota_gone","class":"degraded","message":"m","retry_after_ms":5}"#,
555 )
556 .unwrap();
557 assert_eq!(error.code, "quota_gone");
558 assert_eq!(error.class, ErrorClass::Other("degraded".into()));
559 assert_eq!(error.class.effective(), ErrorClass::Permanent);
560 assert!(!error.is_retried_on_resend());
561 assert_eq!(
562 serde_json::to_string(&error).unwrap(),
563 r#"{"code":"quota_gone","class":"degraded","message":"m","retry_after_ms":5}"#
564 );
565 }
566
567 #[test]
568 fn stored_transient_errors_are_retried_and_permanent_ones_are_not() {
569 for code in [RATE_LIMITED, PROVIDER_ERROR] {
570 assert!(
571 ItemError::for_code(code, "m").is_retried_on_resend(),
572 "{code}"
573 );
574 }
575 for code in [PROVIDER_REFUSED, INVALID_ITEM, COST_EXCEEDED] {
576 assert!(
577 !ItemError::for_code(code, "m").is_retried_on_resend(),
578 "{code}"
579 );
580 }
581 }
582
583 #[test]
584 fn auth_and_model_failures_are_never_stored_and_stop_the_call() {
585 for code in [AUTH_FAILED, MODEL_UNAVAILABLE] {
586 let error = ItemError::for_code(code, "m");
587 assert!(!error.is_stored(), "{code}");
588 assert!(error.stops_the_call(), "{code}");
589 }
590 assert!(ItemError::for_code(RATE_LIMITED, "m").stops_the_call());
591 for code in [
592 PROVIDER_ERROR,
593 PROVIDER_REFUSED,
594 INVALID_ITEM,
595 COST_EXCEEDED,
596 ] {
597 let error = ItemError::for_code(code, "m");
598 assert!(error.is_stored(), "{code}");
599 assert!(!error.stops_the_call(), "{code}");
600 }
601 assert!(ItemError::for_code(RATE_LIMITED, "m").is_stored());
602 assert_eq!(
603 Refusal::model_unavailable("m").detail.model.as_deref(),
604 Some("m")
605 );
606 assert_eq!(Refusal::auth_failed().detail.class, ErrorClass::Permanent);
607 }
608
609 #[test]
610 fn refusals_carry_their_class_and_field() {
611 let refusal = Refusal::over_limit("items", 100, "too many items");
612 assert_eq!(refusal.code, INVALID_PARAMS);
613 assert_eq!(refusal.field(), Some("items"));
614 assert_eq!(refusal.detail.limit, Some(100));
615 assert_eq!(refusal.detail.class, ErrorClass::Permanent);
616 let busy = Refusal::batch_in_progress(250);
617 assert_eq!(busy.detail.class, ErrorClass::Transient);
618 assert_eq!(busy.detail.retry_after_ms, Some(250));
619 }
620}