1use std::collections::HashSet;
5use std::fmt;
6
7use serde::{Deserialize, Serialize};
8use serde_json::{Map as JsonMap, Value as JsonValue};
9
10const SCHEMA_VERSION: u32 = 1;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum VendorErrorClass {
15 AuthenticationRequired,
16 InvalidConfiguration,
17 RateLimited,
18 UnknownRejection,
19}
20
21impl VendorErrorClass {
22 pub fn requires_cooldown(self) -> bool {
23 matches!(self, Self::RateLimited | Self::UnknownRejection)
24 }
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct VendorErrorMatch {
29 pub class: VendorErrorClass,
30 pub vendor: String,
31 pub rule_id: String,
32 pub diagnostic: String,
33}
34
35impl VendorErrorMatch {
36 pub fn evidence_json(&self, cooldown_until_ms: Option<i64>) -> String {
37 serde_json::json!({
38 "class": self.class,
39 "vendor": self.vendor,
40 "rule_id": self.rule_id,
41 "diagnostic": self.diagnostic,
42 "cooldown_until_ms": cooldown_until_ms,
43 })
44 .to_string()
45 }
46}
47
48#[derive(Debug, Clone)]
49pub struct VendorErrorClassifier {
50 rules: Vec<Rule>,
51}
52
53impl VendorErrorClassifier {
54 pub fn built_in() -> Result<Self, CatalogError> {
55 Self::from_yaml(&[
56 ("codex", include_str!("codex/errors.yaml")),
57 ("opencode", include_str!("opencode/errors.yaml")),
58 ("claude", include_str!("claude/errors.yaml")),
59 ])
60 }
61
62 fn from_yaml(catalogs: &[(&str, &str)]) -> Result<Self, CatalogError> {
63 let mut rules = Vec::new();
64 let mut ids = HashSet::new();
65 for (expected_vendor, yaml) in catalogs {
66 let catalog: Catalog = serde_yaml::from_str(yaml).map_err(|error| {
67 CatalogError(format!("invalid {expected_vendor} error catalog: {error}"))
68 })?;
69 if catalog.version != SCHEMA_VERSION {
70 return Err(CatalogError(format!(
71 "unsupported {} error catalog schema version {}; expected {SCHEMA_VERSION}",
72 catalog.vendor, catalog.version
73 )));
74 }
75 if catalog.vendor != *expected_vendor {
76 return Err(CatalogError(format!(
77 "error catalog vendor `{}` does not match `{expected_vendor}`",
78 catalog.vendor
79 )));
80 }
81 for raw in catalog.rules {
82 if raw.id.trim().is_empty() {
83 return Err(CatalogError(format!(
84 "{expected_vendor} error catalog contains an empty rule ID"
85 )));
86 }
87 if !ids.insert(raw.id.clone()) {
88 return Err(CatalogError(format!(
89 "duplicate vendor error rule ID `{}`",
90 raw.id
91 )));
92 }
93 if raw.diagnostic.trim().is_empty() {
94 return Err(CatalogError(format!(
95 "vendor error rule `{}` has an empty diagnostic",
96 raw.id
97 )));
98 }
99 if raw.conditions.message_signatures.is_empty() {
100 return Err(CatalogError(format!(
101 "vendor error rule `{}` has no message signatures",
102 raw.id
103 )));
104 }
105 if raw
106 .conditions
107 .message_signatures
108 .iter()
109 .any(|signature| signature.is_empty())
110 {
111 return Err(CatalogError(format!(
112 "vendor error rule `{}` has an empty message signature",
113 raw.id
114 )));
115 }
116 if !raw.conditions.record_any_of.is_empty() && catalog.framing.is_none() {
117 return Err(CatalogError(format!(
118 "vendor error rule `{}` selects records, but the {expected_vendor} \
119 catalog declares no framing",
120 raw.id
121 )));
122 }
123 if raw
124 .conditions
125 .record_any_of
126 .iter()
127 .any(|predicate| predicate.is_empty())
128 {
129 return Err(CatalogError(format!(
130 "vendor error rule `{}` has an empty record predicate, which would \
131 select every record",
132 raw.id
133 )));
134 }
135 rules.push(Rule {
136 vendor: catalog.vendor.clone(),
137 id: raw.id,
138 class: raw.class,
139 diagnostic: raw.diagnostic,
140 framing: catalog.framing,
141 conditions: raw.conditions,
142 });
143 }
144 }
145 Ok(Self { rules })
146 }
147
148 pub fn classify(
149 &self,
150 exit_status: Option<i32>,
151 stdout: &[u8],
152 stderr: &[u8],
153 ) -> Option<VendorErrorMatch> {
154 let mut scanner = self.scanner(exit_status);
155 scanner.feed_stdout(stdout);
156 scanner.feed_stderr(stderr);
157 scanner.finish()
158 }
159
160 pub fn scanner(&self, exit_status: Option<i32>) -> VendorErrorScanner<'_> {
161 let framed = self.rules.iter().any(|rule| rule.framing.is_some());
162 VendorErrorScanner {
163 classifier: self,
164 exit_status,
165 states: self
166 .rules
167 .iter()
168 .map(|rule| RuleStates {
169 stdout: ConditionState::new(&rule.conditions),
170 stderr: ConditionState::new(&rule.conditions),
171 })
172 .collect(),
173 pending: framed.then(PendingLines::default),
174 }
175 }
176}
177
178pub struct VendorErrorScanner<'a> {
179 classifier: &'a VendorErrorClassifier,
180 exit_status: Option<i32>,
181 states: Vec<RuleStates>,
182 pending: Option<PendingLines>,
184}
185
186#[derive(Default)]
187struct PendingLines {
188 stdout: Vec<u8>,
189 stderr: Vec<u8>,
190}
191
192impl PendingLines {
193 fn get_mut(&mut self, stream: Stream) -> &mut Vec<u8> {
194 match stream {
195 Stream::Stdout => &mut self.stdout,
196 Stream::Stderr => &mut self.stderr,
197 }
198 }
199}
200
201impl VendorErrorScanner<'_> {
202 pub fn feed_stdout(&mut self, bytes: &[u8]) {
203 self.feed(Stream::Stdout, bytes);
204 }
205
206 pub fn feed_stderr(&mut self, bytes: &[u8]) {
207 self.feed(Stream::Stderr, bytes);
208 }
209
210 fn feed(&mut self, stream: Stream, bytes: &[u8]) {
211 self.feed_rules(stream, bytes, |rule| rule.framing.is_none(), None);
212 if self.pending.is_none() {
213 return;
214 }
215 let mut lines = Vec::new();
216 let buffer = self
217 .pending
218 .as_mut()
219 .expect("checked above")
220 .get_mut(stream);
221 buffer.extend_from_slice(bytes);
222 while let Some(index) = buffer.iter().position(|byte| *byte == b'\n') {
223 let mut line: Vec<u8> = buffer.drain(..=index).collect();
224 line.pop();
225 lines.push(line);
226 }
227 for line in lines {
228 self.feed_framed_line(stream, &line);
229 }
230 }
231
232 fn feed_framed_line(&mut self, stream: Stream, line: &[u8]) {
234 let record = serde_json::from_slice::<JsonMap<String, JsonValue>>(line).ok();
235 self.feed_rules(
236 stream,
237 line,
238 |rule| rule.framing.is_some(),
239 Some(record.as_ref()),
240 );
241 }
242
243 fn feed_rules(
244 &mut self,
245 stream: Stream,
246 bytes: &[u8],
247 selects: impl Fn(&Rule) -> bool,
248 record: Option<Option<&JsonMap<String, JsonValue>>>,
249 ) {
250 for (rule, states) in self.classifier.rules.iter().zip(&mut self.states) {
251 if !selects(rule) {
252 continue;
253 }
254 if rule.conditions.stream.is_some() && rule.conditions.stream != Some(stream) {
255 continue;
256 }
257 if let Some(record) = record
258 && !rule.record_eligible(record)
259 {
260 continue;
261 }
262 states.get_mut(stream).feed(&rule.conditions, bytes, false);
263 }
264 }
265
266 pub fn finish(mut self) -> Option<VendorErrorMatch> {
267 if self.pending.is_some() {
268 for stream in [Stream::Stdout, Stream::Stderr] {
269 let rest = std::mem::take(
270 self.pending
271 .as_mut()
272 .expect("checked above")
273 .get_mut(stream),
274 );
275 if !rest.is_empty() {
276 self.feed_framed_line(stream, &rest);
277 }
278 }
279 }
280 for (rule, states) in self.classifier.rules.iter().zip(&mut self.states) {
281 if !rule.matches_exit(self.exit_status) {
282 continue;
283 }
284 states.stdout.feed(&rule.conditions, &[], true);
285 states.stderr.feed(&rule.conditions, &[], true);
286 let matched = match rule.conditions.stream {
287 Some(Stream::Stdout) => states.stdout.matched(),
288 Some(Stream::Stderr) => states.stderr.matched(),
289 None => states.stdout.matched() || states.stderr.matched(),
290 };
291 if matched {
292 return Some(VendorErrorMatch {
293 class: rule.class,
294 vendor: rule.vendor.clone(),
295 rule_id: rule.id.clone(),
296 diagnostic: rule.diagnostic.clone(),
297 });
298 }
299 }
300 None
301 }
302}
303
304#[derive(Debug)]
305pub struct CatalogError(String);
306
307impl fmt::Display for CatalogError {
308 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
309 formatter.write_str(&self.0)
310 }
311}
312
313impl std::error::Error for CatalogError {}
314
315#[derive(Debug, Deserialize)]
316#[serde(deny_unknown_fields)]
317struct Catalog {
318 version: u32,
319 vendor: String,
320 #[serde(default)]
321 framing: Option<Framing>,
322 rules: Vec<RawRule>,
323}
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
332#[serde(rename_all = "snake_case")]
333enum Framing {
334 Ndjson,
337}
338
339#[derive(Debug, Deserialize)]
340#[serde(deny_unknown_fields)]
341struct RawRule {
342 id: String,
343 class: VendorErrorClass,
344 diagnostic: String,
345 #[serde(rename = "match")]
346 conditions: MatchConditions,
347}
348
349#[derive(Debug, Clone)]
350struct Rule {
351 vendor: String,
352 id: String,
353 class: VendorErrorClass,
354 diagnostic: String,
355 framing: Option<Framing>,
356 conditions: MatchConditions,
357}
358
359impl Rule {
360 fn matches_exit(&self, exit_status: Option<i32>) -> bool {
361 self.conditions
362 .exit_status
363 .is_none_or(|expected| exit_status == Some(expected))
364 }
365
366 fn record_eligible(&self, record: Option<&JsonMap<String, JsonValue>>) -> bool {
375 let Some(record) = record else {
376 return true;
377 };
378 self.conditions.record_any_of.is_empty()
379 || self.conditions.record_any_of.iter().any(|predicate| {
380 predicate
381 .iter()
382 .all(|(field, expected)| record.get(field) == Some(expected))
383 })
384 }
385}
386
387#[derive(Debug, Clone, Deserialize)]
388#[serde(deny_unknown_fields)]
389struct MatchConditions {
390 #[serde(default)]
391 exit_status: Option<i32>,
392 #[serde(default)]
393 stream: Option<Stream>,
394 #[serde(default)]
395 status_code: Option<u16>,
396 #[serde(default)]
397 error_code: Option<String>,
398 #[serde(default)]
402 record_any_of: Vec<JsonMap<String, JsonValue>>,
403 message_signatures: Vec<String>,
404}
405
406impl MatchConditions {
407 fn overlap_len(&self) -> usize {
408 let signatures = self
409 .message_signatures
410 .iter()
411 .map(|signature| signature.len());
412 let status = self
413 .status_code
414 .into_iter()
415 .flat_map(status_code_patterns)
416 .map(|pattern| pattern.len());
417 let error = self
418 .error_code
419 .as_deref()
420 .into_iter()
421 .flat_map(error_code_patterns)
422 .map(|pattern| pattern.len());
423 signatures.chain(status).chain(error).max().unwrap_or(1)
424 }
425}
426
427#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
428#[serde(rename_all = "snake_case")]
429enum Stream {
430 Stdout,
431 Stderr,
432}
433
434struct RuleStates {
435 stdout: ConditionState,
436 stderr: ConditionState,
437}
438
439impl RuleStates {
440 fn get_mut(&mut self, stream: Stream) -> &mut ConditionState {
441 match stream {
442 Stream::Stdout => &mut self.stdout,
443 Stream::Stderr => &mut self.stderr,
444 }
445 }
446}
447
448struct ConditionState {
449 status_found: bool,
450 error_found: bool,
451 signatures_found: Vec<bool>,
452 tail: Vec<u8>,
453 overlap_len: usize,
454}
455
456impl ConditionState {
457 fn new(conditions: &MatchConditions) -> Self {
458 Self {
459 status_found: conditions.status_code.is_none(),
460 error_found: conditions.error_code.is_none(),
461 signatures_found: vec![false; conditions.message_signatures.len()],
462 tail: Vec::new(),
463 overlap_len: conditions.overlap_len(),
464 }
465 }
466
467 fn feed(&mut self, conditions: &MatchConditions, bytes: &[u8], final_chunk: bool) {
468 let mut window = std::mem::take(&mut self.tail);
469 window.extend_from_slice(bytes);
470 if !self.status_found
471 && let Some(code) = conditions.status_code
472 {
473 self.status_found = contains_status_code(&window, code, final_chunk);
474 }
475 if !self.error_found
476 && let Some(code) = conditions.error_code.as_deref()
477 {
478 self.error_found = contains_error_code(&window, code, final_chunk);
479 }
480 for (found, signature) in self
481 .signatures_found
482 .iter_mut()
483 .zip(&conditions.message_signatures)
484 {
485 *found |= contains(&window, signature.as_bytes());
486 }
487 let keep = self.overlap_len.min(window.len());
488 self.tail.extend_from_slice(&window[window.len() - keep..]);
489 }
490
491 fn matched(&self) -> bool {
492 self.status_found && self.error_found && self.signatures_found.iter().all(|found| *found)
493 }
494}
495
496fn contains(bytes: &[u8], needle: &[u8]) -> bool {
497 !needle.is_empty() && bytes.windows(needle.len()).any(|window| window == needle)
498}
499
500fn status_code_patterns(code: u16) -> Vec<String> {
501 let code = code.to_string();
502 vec![
503 format!("status {code}"),
504 format!("status: {code}"),
505 format!("status={code}"),
506 format!("\"status\":{code}"),
507 format!("\"status\": {code}"),
508 format!("\"status_code\":{code}"),
509 format!("\"statuscode\":{code}"),
510 ]
511}
512
513fn contains_status_code(bytes: &[u8], code: u16, allow_end: bool) -> bool {
514 let text = String::from_utf8_lossy(bytes).to_ascii_lowercase();
515 status_code_patterns(code).iter().any(|pattern| {
516 contains_with_boundary(&text, pattern, allow_end, |byte| byte.is_ascii_digit())
517 })
518}
519
520fn error_code_patterns(code: &str) -> Vec<String> {
521 let code = code.to_ascii_lowercase();
522 vec![
523 format!("code {code}"),
524 format!("code: {code}"),
525 format!("code={code}"),
526 format!("\"code\":\"{code}\""),
527 format!("\"code\": \"{code}\""),
528 format!("\"error_code\":\"{code}\""),
529 ]
530}
531
532fn contains_error_code(bytes: &[u8], code: &str, allow_end: bool) -> bool {
533 let text = String::from_utf8_lossy(bytes).to_ascii_lowercase();
534 error_code_patterns(code).iter().any(|pattern| {
535 contains_with_boundary(&text, pattern, allow_end, |byte| {
536 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')
537 })
538 })
539}
540
541fn contains_with_boundary(
542 haystack: &str,
543 needle: &str,
544 allow_end: bool,
545 continues_value: impl Fn(u8) -> bool,
546) -> bool {
547 haystack.match_indices(needle).any(|(start, _)| {
548 haystack
549 .as_bytes()
550 .get(start + needle.len())
551 .map_or(allow_end, |byte| !continues_value(*byte))
552 })
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558
559 const VALID: &str = r#"
560version: 1
561vendor: test
562rules:
563 - id: test.rate-limit
564 class: rate_limited
565 diagnostic: Request rate limited
566 match:
567 exit_status: 1
568 stream: stderr
569 status_code: 429
570 error_code: rate_limit_exceeded
571 message_signatures: ["try again later"]
572"#;
573
574 #[test]
575 fn built_in_catalogs_validate_and_match_captured_vendor_fixtures() {
576 let classifier = VendorErrorClassifier::built_in().unwrap();
577 let cases = [
578 (
579 br#"Error: status 401 Missing bearer or basic authentication in header"#.as_slice(),
580 VendorErrorClass::AuthenticationRequired,
581 "codex.authentication.missing-header",
582 ),
583 (
584 br#"request failed: status: 400 model is not supported when using Codex with a ChatGPT account"#.as_slice(),
585 VendorErrorClass::InvalidConfiguration,
586 "codex.configuration.unsupported-chatgpt-model",
587 ),
588 (
589 br#"UnknownError: Unexpected server error. Check server logs for details."#.as_slice(),
590 VendorErrorClass::UnknownRejection,
591 "opencode.rejection.unexpected-server-error",
592 ),
593 (
594 br#"API Error: You've hit your limit; resets 12am (UTC)"#.as_slice(),
595 VendorErrorClass::RateLimited,
596 "claude.rate-limit.usage-limit",
597 ),
598 (
599 br#"API Error: 529 Overloaded. This is a server-side issue, usually temporary"#
600 .as_slice(),
601 VendorErrorClass::RateLimited,
602 "claude.rate-limit.api-overloaded",
603 ),
604 ];
605 for (stderr, class, id) in cases {
606 let matched = classifier.classify(Some(1), b"", stderr).unwrap();
607 assert_eq!(matched.class, class);
608 assert_eq!(matched.rule_id, id);
609 }
610 }
611
612 const CLAUDE_SESSION_LIMIT_STDOUT: &str = r#"{"type":"assistant","message":{"id":"462199bc-1a76-4c09-aaff-df681c45b92c","model":"<synthetic>","role":"assistant","stop_reason":"stop_sequence","stop_sequence":"","type":"message","content":[{"type":"text","text":"You've hit your session limit · resets 12:50pm (Australia/Sydney)"}]},"session_id":"7f4420d2-0557-4535-849c-975a1adda3bc","error":"rate_limit","is_api_error_message":true}"#;
616
617 #[test]
620 fn a_claude_session_limit_on_stdout_is_rate_limited() {
621 let classifier = VendorErrorClassifier::built_in().unwrap();
622 let matched = classifier
623 .classify(Some(1), CLAUDE_SESSION_LIMIT_STDOUT.as_bytes(), b"")
624 .expect("the captured session limit classifies");
625 assert_eq!(matched.class, VendorErrorClass::RateLimited);
626 assert_eq!(matched.rule_id, "claude.rate-limit.usage-limit");
627 assert!(matched.class.requires_cooldown());
628
629 let plain = b"API Error: You've hit your limit; resets 12am (UTC)".as_slice();
630 assert!(classifier.classify(Some(1), plain, b"").is_some());
631 assert!(classifier.classify(Some(1), b"", plain).is_some());
632 assert!(
633 classifier
634 .classify(Some(1), b"error: could not compile `sloop`", b"")
635 .is_none()
636 );
637 }
638
639 const CLAUDE_LIMIT_RESULT: &str = r#"{"type":"result","subtype":"success","is_error":true,"num_turns":49,"result":"You've hit your session limit · resets 12:50pm (Australia/Sydney)"}"#;
642
643 const AGENT_QUOTING_THE_LIMIT: &[&str] = &[
647 r##"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"const CLAUDE_SESSION_LIMIT_STDOUT: &str = r#\"{\"type\":\"assistant\",\"message\":{\"content\":[{\"text\":\"You've hit your session limit\"}]},\"error\":\"rate_limit\",\"is_api_error_message\":true}\"#;"}]}}"##,
648 r#"{"type":"assistant","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"tool_use","id":"toolu_02","name":"Bash","input":{"command":"rg -n \"You've hit your\" src/","description":"Find the limit signature"}}]}}"#,
649 r#"{"type":"assistant","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"The catalog matches on \"You've hit your\", which the repository also contains."}]}}"#,
650 r#"{"type":"result","subtype":"success","is_error":false,"num_turns":113,"result":"Done. The rule keys on \"You've hit your\" and now also checks record structure."}"#,
651 ];
652
653 #[test]
659 fn an_agent_reading_or_quoting_a_limit_is_not_a_limit() {
660 let classifier = VendorErrorClassifier::built_in().unwrap();
661 for record in AGENT_QUOTING_THE_LIMIT {
662 let stdout = format!("{record}\n");
663 assert!(
664 classifier
665 .classify(Some(0), stdout.as_bytes(), b"")
666 .is_none(),
667 "agent-authored record classified as a vendor rejection: {record}"
668 );
669 }
670
671 let transcript = format!("{}\n", AGENT_QUOTING_THE_LIMIT.join("\n"));
672 let mut scanner = classifier.scanner(Some(0));
673 for chunk in transcript.as_bytes().chunks(17) {
674 scanner.feed_stdout(chunk);
675 }
676 assert!(scanner.finish().is_none());
677 }
678
679 #[test]
682 fn a_vendor_authored_limit_still_classifies_among_agent_traffic() {
683 let classifier = VendorErrorClassifier::built_in().unwrap();
684 for genuine in [CLAUDE_SESSION_LIMIT_STDOUT, CLAUDE_LIMIT_RESULT] {
685 let matched = classifier
686 .classify(Some(0), format!("{genuine}\n").as_bytes(), b"")
687 .expect("a vendor-authored limit classifies");
688 assert_eq!(matched.rule_id, "claude.rate-limit.usage-limit");
689
690 let mut transcript = AGENT_QUOTING_THE_LIMIT.join("\n");
691 transcript.push('\n');
692 transcript.push_str(genuine);
693 assert!(
694 classifier
695 .classify(Some(0), transcript.as_bytes(), b"")
696 .is_some(),
697 "a real limit was masked by surrounding agent traffic"
698 );
699 }
700 }
701
702 #[test]
705 fn unframed_output_and_catalogs_are_unaffected_by_framing() {
706 let classifier = VendorErrorClassifier::built_in().unwrap();
707 let plain = b"You've hit your session limit \xc2\xb7 resets 12am (UTC)".as_slice();
708 assert!(classifier.classify(Some(1), plain, b"").is_some());
709 assert!(classifier.classify(Some(1), b"", plain).is_some());
710
711 let opencode = b"UnknownError:\nUnexpected server error. Check server logs for details.";
712 assert!(classifier.classify(Some(1), b"", opencode).is_some());
713 }
714
715 #[test]
716 fn schema_validation_rejects_record_predicates_without_framing() {
717 let selecting = VALID.replace(
718 " message_signatures:",
719 " record_any_of:\n - is_api_error_message: true\n message_signatures:",
720 );
721 let error = VendorErrorClassifier::from_yaml(&[("test", &selecting)])
722 .unwrap_err()
723 .to_string();
724 assert!(error.contains("declares no framing"), "{error}");
725
726 let framed = format!("framing: ndjson\n{selecting}");
727 assert!(VendorErrorClassifier::from_yaml(&[("test", &framed)]).is_ok());
728
729 let empty = framed.replace("- is_api_error_message: true", "- {}");
730 let error = VendorErrorClassifier::from_yaml(&[("test", &empty)])
731 .unwrap_err()
732 .to_string();
733 assert!(error.contains("empty record predicate"), "{error}");
734 }
735
736 #[test]
737 fn schema_validation_rejects_unknown_classes_duplicate_ids_and_empty_signatures() {
738 let unknown = VALID.replace("rate_limited", "retry_someday");
739 assert!(VendorErrorClassifier::from_yaml(&[("test", &unknown)]).is_err());
740
741 let duplicate = format!("{VALID}\n{}", "");
742 assert!(
743 VendorErrorClassifier::from_yaml(&[("test", &duplicate), ("test", VALID)]).is_err()
744 );
745
746 let empty = VALID.replace("[\"try again later\"]", "[\"\"]");
747 let error = VendorErrorClassifier::from_yaml(&[("test", &empty)])
748 .unwrap_err()
749 .to_string();
750 assert!(error.contains("empty message signature"), "{error}");
751 }
752
753 #[test]
754 fn all_conditions_must_match_in_one_selected_stream() {
755 let classifier = VendorErrorClassifier::from_yaml(&[("test", VALID)]).unwrap();
756 let matching =
757 br#"{"status":429,"code":"rate_limit_exceeded","message":"try again later"}"#;
758 assert!(classifier.classify(Some(1), b"", matching).is_some());
759 assert!(classifier.classify(Some(0), b"", matching).is_none());
760 assert!(classifier.classify(Some(1), matching, b"").is_none());
761 assert!(
762 classifier
763 .classify(
764 Some(1),
765 br#"{"status":429,"code":"rate_limit_exceeded"}"#,
766 b"try again later"
767 )
768 .is_none()
769 );
770
771 let mut scanner = classifier.scanner(Some(1));
772 for chunk in [
773 b"{\"sta".as_slice(),
774 b"tus\":429,\"code\":\"rate_",
775 b"limit_exceeded\",\"message\":\"try ag",
776 b"ain later\"}",
777 ] {
778 scanner.feed_stderr(chunk);
779 }
780 assert!(scanner.finish().is_some());
781 }
782
783 #[test]
784 fn duplicate_signatures_binary_bytes_and_near_matches_are_safe() {
785 let classifier = VendorErrorClassifier::from_yaml(&[("test", VALID)]).unwrap();
786 let mut bytes = vec![0xff, 0x00];
787 bytes.extend_from_slice(
788 br#"status 429 code rate_limit_exceeded try again later try again later"#,
789 );
790 assert!(classifier.classify(Some(1), b"", &bytes).is_some());
791 assert!(
792 classifier
793 .classify(
794 Some(1),
795 b"",
796 b"status 429 code rate_limit_exceeded try again much later"
797 )
798 .is_none()
799 );
800 assert!(
801 classifier
802 .classify(Some(1), b"", b"plain number 429")
803 .is_none()
804 );
805 assert!(
806 classifier
807 .classify(
808 Some(1),
809 b"",
810 b"status 4290 code rate_limit_exceeded try again later"
811 )
812 .is_none()
813 );
814 }
815}