1use std::time::Duration;
4
5use aither_core::llm::{
6 Classify, Failure, FailureKind,
7 failure::zenwave::{body_failure, failure as http_failure, sse_failure},
8};
9
10#[derive(Debug, thiserror::Error)]
12pub enum CopilotError {
13 #[error("HTTP error: {0}")]
15 Http(#[from] zenwave::Error),
16
17 #[error("Body error: {0}")]
19 Body(#[from] zenwave::BodyError),
20
21 #[error("SSE error: {0}")]
23 Stream(#[from] zenwave::sse::ParseError),
24
25 #[error("JSON error: {0}")]
27 Json(#[from] serde_json::Error),
28
29 #[error("{0}")]
31 Api(String),
32
33 #[error("Authorization pending")]
35 AuthorizationPending,
36
37 #[error("Device code expired")]
39 DeviceCodeExpired,
40
41 #[error("Access denied")]
43 AccessDenied,
44
45 #[error("Rate limit exceeded: {message}{}", retry_after.map(|d| format!(" (retry after {}s)", d.as_secs())).unwrap_or_default())]
47 RateLimit {
48 message: String,
50 retry_after: Option<Duration>,
52 },
53
54 #[error("Server error {status}: {message}")]
56 ServerError {
57 status: u16,
59 message: String,
61 },
62
63 #[error("Request timed out")]
65 Timeout,
66}
67
68impl CopilotError {
69 #[must_use]
74 pub const fn status(&self) -> Option<u16> {
75 match self {
76 Self::Http(zenwave::Error::Http { status, .. }) => Some(status.as_u16()),
77 Self::ServerError { status, .. } => Some(*status),
78 _ => None,
79 }
80 }
81}
82
83fn body_error_code(body: &str) -> Option<String> {
85 #[derive(serde::Deserialize)]
86 struct Body {
87 error: Detail,
88 }
89 #[derive(serde::Deserialize)]
90 struct Detail {
91 code: Option<String>,
92 }
93 serde_json::from_str::<Body>(body)
94 .ok()
95 .and_then(|body| body.error.code)
96}
97
98impl Classify for CopilotError {
99 fn failure(&self) -> Failure {
100 match self {
101 Self::Http(err) => {
102 let mut failure = http_failure(err);
103 let payment_required = err.response().is_some_and(|response| {
104 response.status() == zenwave::StatusCode::PAYMENT_REQUIRED
105 });
106 let quota_code = err
107 .response_body()
108 .and_then(body_error_code)
109 .is_some_and(|code| code == "quota_exceeded" || code == "insufficient_quota");
110 if payment_required || quota_code {
111 failure.kind = FailureKind::Quota;
112 }
113 failure
114 }
115 Self::Body(err) => body_failure(err),
116 Self::Stream(err) => sse_failure(err),
117 Self::RateLimit { retry_after, .. } => {
118 Failure::transient().with_retry_after(*retry_after)
119 }
120 Self::ServerError { .. } => Failure::transient(),
121 Self::Timeout => Failure::network(),
122 Self::Json(_)
123 | Self::Api(_)
124 | Self::AuthorizationPending
125 | Self::DeviceCodeExpired
126 | Self::AccessDenied => Failure::permanent(),
127 }
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use std::time::Duration;
134
135 use aither_core::{
136 LanguageModel,
137 llm::{Classify, FailureKind, failure::zenwave::http_error, oneshot},
138 };
139 use futures_lite::StreamExt;
140
141 use super::CopilotError;
142 use crate::Copilot;
143
144 fn http(status: u16, headers: &[(&str, &str)], body: &str) -> CopilotError {
145 CopilotError::Http(http_error(status, headers, body))
146 }
147
148 #[test]
149 fn server_errors_are_transient() {
150 assert_eq!(http(500, &[], "").failure().kind, FailureKind::Transient);
151 assert_eq!(http(529, &[], "").failure().kind, FailureKind::Transient);
152 }
153
154 #[test]
155 fn quota_and_rate_limit() {
156 let quota = r#"{"error":{"code":"quota_exceeded","message":"m"}}"#;
157 assert_eq!(http(429, &[], quota).failure().kind, FailureKind::Quota);
158 assert_eq!(http(402, &[], "").failure().kind, FailureKind::Quota);
159 assert_eq!(http(429, &[], "").failure().kind, FailureKind::Transient);
160 }
161
162 #[test]
163 fn unauthorized_is_permanent() {
164 assert_eq!(http(401, &[], "").failure().kind, FailureKind::Permanent);
165 }
166
167 #[test]
168 fn retry_after_seconds_and_date() {
169 let failure = http(429, &[("retry-after", "12")], "").failure();
170 assert_eq!(failure.retry_after, Some(Duration::from_secs(12)));
171 let failure = http(503, &[("retry-after", "Fri, 01 Jan 2100 00:00:00 GMT")], "").failure();
172 assert!(
173 failure
174 .retry_after
175 .is_some_and(|d| d > Duration::from_secs(3600))
176 );
177 }
178
179 #[tokio::test]
180 async fn refused_connection_is_network() {
181 let port = std::net::TcpListener::bind("127.0.0.1:0")
182 .unwrap()
183 .local_addr()
184 .unwrap()
185 .port();
186 let client = Copilot::new("token").with_base_url(format!("http://127.0.0.1:{port}"));
187 let stream = client.respond(oneshot("system", "hello"));
188 futures_lite::pin!(stream);
189 let error = stream
190 .next()
191 .await
192 .expect("stream yields")
193 .expect_err("connection is refused");
194 assert_eq!(error.failure().kind, FailureKind::Network, "{error}");
195 }
196}