Skip to main content

aither_copilot/
error.rs

1//! Error types for GitHub Copilot API integration.
2
3use 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/// Errors that can arise when calling the GitHub Copilot API.
11#[derive(Debug, thiserror::Error)]
12pub enum CopilotError {
13    /// HTTP layer errors from zenwave.
14    #[error("HTTP error: {0}")]
15    Http(#[from] zenwave::Error),
16
17    /// Response body parsing failures.
18    #[error("Body error: {0}")]
19    Body(#[from] zenwave::BodyError),
20
21    /// SSE parsing failures.
22    #[error("SSE error: {0}")]
23    Stream(#[from] zenwave::sse::ParseError),
24
25    /// JSON serialization/deserialization errors.
26    #[error("JSON error: {0}")]
27    Json(#[from] serde_json::Error),
28
29    /// API contract violations or unsupported operations.
30    #[error("{0}")]
31    Api(String),
32
33    /// OAuth authorization pending (user hasn't completed flow yet).
34    #[error("Authorization pending")]
35    AuthorizationPending,
36
37    /// OAuth device code expired.
38    #[error("Device code expired")]
39    DeviceCodeExpired,
40
41    /// OAuth access denied by user.
42    #[error("Access denied")]
43    AccessDenied,
44
45    /// Rate limit exceeded (HTTP 429).
46    #[error("Rate limit exceeded: {message}{}", retry_after.map(|d| format!(" (retry after {}s)", d.as_secs())).unwrap_or_default())]
47    RateLimit {
48        /// Message from the API.
49        message: String,
50        /// Suggested retry delay from Retry-After header.
51        retry_after: Option<Duration>,
52    },
53
54    /// Server error (HTTP 5xx).
55    #[error("Server error {status}: {message}")]
56    ServerError {
57        /// HTTP status code.
58        status: u16,
59        /// Error message.
60        message: String,
61    },
62
63    /// Request timed out.
64    #[error("Request timed out")]
65    Timeout,
66}
67
68impl CopilotError {
69    /// HTTP status code carried by transport-level errors, if any.
70    ///
71    /// Keeps status-code inspection inside this crate so callers do not need
72    /// to name the underlying HTTP client's error type.
73    #[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
83/// The `error.code` of an OpenAI-style error body.
84fn 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}