返回 CodeWhale
tests.rs
根目录 / crates / tui / src / llm_client / tests.rs
1 use super::*;
2
3 #[test]
4 fn retryability_distinguishes_transient_failures_from_durable_failures() {
5 for error in [
6 LlmError::RateLimited {
7 message: "too many requests".into(),
8 retry_after: None,
9 },
10 LlmError::ServerError {
11 status: 500,
12 message: "internal error".into(),
13 },
14 LlmError::NetworkError("connection refused".into()),
15 LlmError::Timeout(Duration::from_secs(30)),
16 ] {
17 assert!(error.is_retryable(), "expected transient error: {error}");
18 }
19 for error in [
20 LlmError::authentication_error("invalid key"),
21 LlmError::AuthorizationError("blocked".into()),
22 LlmError::InvalidRequest {
23 status: 400,
24 message: "bad json".into(),
25 },
26 LlmError::ContentPolicyError("unsafe content".into()),
27 LlmError::ContextLengthError("too long".into()),
28 ] {
29 assert!(!error.is_retryable(), "expected durable error: {error}");
30 }
31 }
32
33 #[test]
34 fn http_response_boundary_classifies_status_contract() {
35 assert!(matches!(
36 LlmError::from_http_response(429, "rate limit exceeded"),
37 LlmError::RateLimited { .. }
38 ));
39 assert!(matches!(
40 LlmError::from_http_response(401, "invalid api key"),
41 LlmError::AuthenticationError(_)
42 ));
43 assert!(matches!(
44 LlmError::from_http_response(403, "forbidden"),
45 LlmError::AuthorizationError(_)
46 ));
47 assert!(matches!(
48 LlmError::from_http_response(403, "invalid api key"),
49 LlmError::AuthenticationError(_)
50 ));
51 let cancelled = LlmError::from_http_response(499, "upstream request cancelled");
52 assert!(matches!(
53 &cancelled,
54 LlmError::ServerError { status: 499, .. }
55 ));
56 assert!(cancelled.is_retryable());
57 assert!(matches!(
58 LlmError::from_http_response(500, "internal server error"),
59 LlmError::ServerError { status: 500, .. }
60 ));
61 assert!(matches!(
62 LlmError::from_http_response(503, "service unavailable"),
63 LlmError::ServerError { status: 503, .. }
64 ));
65 assert!(matches!(
66 LlmError::from_http_response(400, "context_length_exceeded"),
67 LlmError::ContextLengthError(_)
68 ));
69 assert!(matches!(
70 LlmError::from_http_response(400, "content_policy_violation"),
71 LlmError::ContentPolicyError(_)
72 ));
73 assert!(matches!(
74 LlmError::from_http_response(400, "invalid json"),
75 LlmError::InvalidRequest { status: 400, .. }
76 ));
77 }
78
79 #[test]
80 fn explicit_400_402_and_429_quota_responses_are_typed_and_non_retryable() {
81 for (status, body) in [
82 (
83 400,
84 r#"{"error":{"code":"insufficient_quota","message":"You exceeded your current quota"}}"#,
85 ),
86 (
87 429,
88 r#"{"error":{"type":"insufficient_quota","message":"Billing limit reached"}}"#,
89 ),
90 (
91 402,
92 r#"{"error":{"code":"billing_hard_limit_reached","message":"Payment required"}}"#,
93 ),
94 (
95 429,
96 "You exceeded your current quota. Please check your plan and billing details.",
97 ),
98 (429, "Account quota exhausted"),
99 ] {
100 let error = LlmError::from_http_response(status, body);
101 assert!(matches!(error, LlmError::QuotaExhausted(_)));
102 assert!(!error.is_retryable());
103 }
104
105 let raw = r#"{"error":{"code":"billing_hard_limit_reached","message":"Account unavailable"}}"#;
106 let safe = sanitize_http_error_body(Some("fixture"), 429, raw);
107 assert!(matches!(
108 LlmError::from_http_response(429, &safe),
109 LlmError::QuotaExhausted(_)
110 ));
111 }
112
113 #[test]
114 fn generic_429_stays_rate_limited_and_retryable() {
115 for body in [
116 "Too Many Requests",
117 "Rate limit on your API quota exceeded",
118 "Requests per minute quota exceeded",
119 "Quota rate limit exceeded; retry after 10 seconds",
120 ] {
121 let error = LlmError::from_http_response(429, body);
122 assert!(
123 matches!(error, LlmError::RateLimited { .. }),
124 "expected transient rate limit for {body:?}, got {error:?}"
125 );
126 assert!(error.is_retryable());
127 }
128
129 let raw = r#"{"error":{"code":"RESOURCE_EXHAUSTED","message":"Rate limit on your API quota exceeded"}}"#;
130 let safe = sanitize_http_error_body(Some("fixture"), 429, raw);
131 let error = LlmError::from_http_response(429, &safe);
132 assert!(matches!(error, LlmError::RateLimited { .. }));
133 assert!(error.is_retryable());
134 }
135
136 #[tokio::test]
137 async fn retry_loop_stops_after_one_typed_quota_failure() {
138 let mut calls = 0;
139 let result: RetryResult<i32> = with_retry(
140 &RetryConfig::default(),
141 || {
142 calls += 1;
143 async {
144 Err(LlmError::from_http_response(
145 429,
146 r#"{"error":{"code":"insufficient_quota"}}"#,
147 ))
148 }
149 },
150 None,
151 )
152 .await;
153 assert_eq!(result.unwrap_err().attempts, 1);
154 assert_eq!(calls, 1);
155 }
156
157 #[tokio::test]
158 async fn retry_loop_stops_after_one_authentication_failure() {
159 let mut calls = 0;
160 let result: RetryResult<i32> = with_retry(
161 &RetryConfig::default(),
162 || {
163 calls += 1;
164 async { Err(LlmError::authentication_error("bad key")) }
165 },
166 None,
167 )
168 .await;
169 assert!(result.is_err());
170 assert_eq!(calls, 1);
171 }
172
172 lines RUST