Skip to main content

hey_sdk/
error.rs

1use std::fmt;
2
3use serde::de::DeserializeOwned;
4
5use crate::http::{HeaderMap, Method, StatusCode};
6
7/// The call succeeded.
8pub const EXIT_OK: i32 = 0;
9/// Invalid arguments or flags.
10pub const EXIT_USAGE: i32 = 1;
11/// Resource not found.
12pub const EXIT_NOT_FOUND: i32 = 2;
13/// Not authenticated.
14pub const EXIT_AUTH: i32 = 3;
15/// Access denied, usually a scope the token does not carry.
16pub const EXIT_FORBIDDEN: i32 = 4;
17/// Rate limited (429).
18pub const EXIT_RATE_LIMIT: i32 = 5;
19/// Connection, DNS or timeout failure.
20pub const EXIT_NETWORK: i32 = 6;
21/// The server returned an error.
22pub const EXIT_API: i32 = 7;
23/// A name matched more than one record.
24pub const EXIT_AMBIGUOUS: i32 = 8;
25/// The server rejected the contents of the request (422), or the request conflicts with
26/// the state the server already holds (409).
27pub const EXIT_VALIDATION: i32 = 9;
28
29/// The most of a failure's body an error keeps, mirroring Go's `MaxErrorBodyBytes`. A body
30/// past it is kept up to the bound and no further: an error is diagnostic, and a server
31/// answering a refusal with a megabyte of anything has said everything useful long before.
32pub const MAX_ERROR_BODY_BYTES: usize = 1 << 20;
33
34/// The most of a server's own message an error's hint carries, mirroring Go's
35/// `MaxErrorMessageBytes`. A longer one is cut and ends in `...`.
36pub const MAX_ERROR_MESSAGE_BYTES: usize = 500;
37
38/// Machine-readable error categories, shared with the other HEY SDKs.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub enum ErrorCode {
41    Usage,
42    NotFound,
43    Auth,
44    Forbidden,
45    RateLimit,
46    Network,
47    Api,
48    Validation,
49    Ambiguous,
50    Conflict,
51    /// The scope's circuit breaker is open, so the SDK refused the call itself. See
52    /// [`crate::resilience`].
53    CircuitOpen,
54    /// The scope already has as many calls in flight as its bulkhead allows.
55    BulkheadFull,
56}
57
58impl ErrorCode {
59    pub fn as_str(&self) -> &'static str {
60        match self {
61            ErrorCode::Usage => "usage",
62            ErrorCode::NotFound => "not_found",
63            ErrorCode::Auth => "auth_required",
64            ErrorCode::Forbidden => "forbidden",
65            ErrorCode::RateLimit => "rate_limit",
66            ErrorCode::Network => "network",
67            ErrorCode::Api => "api_error",
68            ErrorCode::Validation => "validation",
69            ErrorCode::Ambiguous => "ambiguous",
70            ErrorCode::Conflict => "conflict",
71            ErrorCode::CircuitOpen => "circuit_open",
72            ErrorCode::BulkheadFull => "bulkhead_full",
73        }
74    }
75
76    /// The process exit status a command should end with for this category. A conflict
77    /// leaves with [`EXIT_VALIDATION`]: both mean the server refused what was asked of it.
78    /// A call the SDK refused for itself leaves with [`EXIT_API`], which is where Go's
79    /// `ExitCodeFor` sends the codes it does not name.
80    pub fn exit_code(&self) -> i32 {
81        match self {
82            ErrorCode::Usage => EXIT_USAGE,
83            ErrorCode::NotFound => EXIT_NOT_FOUND,
84            ErrorCode::Auth => EXIT_AUTH,
85            ErrorCode::Forbidden => EXIT_FORBIDDEN,
86            ErrorCode::RateLimit => EXIT_RATE_LIMIT,
87            ErrorCode::Network => EXIT_NETWORK,
88            ErrorCode::Api => EXIT_API,
89            ErrorCode::Validation => EXIT_VALIDATION,
90            ErrorCode::Ambiguous => EXIT_AMBIGUOUS,
91            ErrorCode::Conflict => EXIT_VALIDATION,
92            ErrorCode::CircuitOpen | ErrorCode::BulkheadFull => EXIT_API,
93        }
94    }
95}
96
97impl fmt::Display for ErrorCode {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        f.write_str(self.as_str())
100    }
101}
102
103/// The error every SDK call can answer with.
104#[derive(Debug)]
105pub struct Error {
106    code: ErrorCode,
107    message: String,
108    hint: Option<String>,
109    http_status: Option<u16>,
110    retryable: bool,
111    request_id: Option<String>,
112    source: Option<Box<dyn std::error::Error + Send + Sync>>,
113    response_too_large: bool,
114    /// What HEY answered the failure with, kept whole so a caller can read the model's own
115    /// account of the refusal. See [`Error::body`]. A boxed slice rather than a
116    /// [`bytes::Bytes`]: an error is never cloned, so there is nothing for the sharing to
117    /// buy, and half the width keeps every `Result` in the crate under clippy's
118    /// `result_large_err` bound.
119    body: Option<Box<[u8]>>,
120}
121
122impl Error {
123    pub fn new(code: ErrorCode, message: impl Into<String>) -> Error {
124        Error {
125            code,
126            message: message.into(),
127            hint: None,
128            http_status: None,
129            retryable: false,
130            request_id: None,
131            source: None,
132            response_too_large: false,
133            body: None,
134        }
135    }
136
137    pub fn usage(message: impl Into<String>) -> Error {
138        Error::new(ErrorCode::Usage, message)
139    }
140
141    pub fn usage_with_hint(message: impl Into<String>, hint: impl Into<String>) -> Error {
142        Error::usage(message).with_hint(hint)
143    }
144
145    pub fn not_found(resource: &str, identifier: impl fmt::Display) -> Error {
146        Error::new(
147            ErrorCode::NotFound,
148            format!("{resource} not found: {identifier}"),
149        )
150        .with_status(404)
151    }
152
153    pub fn not_found_with_hint(
154        resource: &str,
155        identifier: impl fmt::Display,
156        hint: impl Into<String>,
157    ) -> Error {
158        Error::not_found(resource, identifier).with_hint(hint)
159    }
160
161    pub fn auth(message: impl Into<String>) -> Error {
162        Error::new(ErrorCode::Auth, message).with_status(401)
163    }
164
165    pub fn forbidden(message: impl Into<String>) -> Error {
166        Error::new(ErrorCode::Forbidden, message).with_status(403)
167    }
168
169    pub fn forbidden_scope() -> Error {
170        Error::forbidden("Access denied: insufficient scope")
171            .with_hint("Re-authenticate with full scope")
172    }
173
174    /// The rate-limit error a caller raises for itself, worded the way the other SDKs word
175    /// theirs: "Rate limited". A 429 that came back from HEY reads "rate limited - try
176    /// again later" instead — see [`Error::from_response`].
177    pub fn rate_limit(retry_after: Option<u64>) -> Error {
178        Error::new(ErrorCode::RateLimit, "Rate limited")
179            .with_hint(retry_hint(retry_after))
180            .with_status(429)
181            .retryable()
182    }
183
184    /// The client's own rate limiter refused a call, so nothing was sent. It shares
185    /// [`ErrorCode::RateLimit`] with a 429 from HEY and is told apart by carrying no HTTP
186    /// status; Go keeps the two apart with a sentinel instead.
187    pub fn rate_limited() -> Error {
188        Error::new(ErrorCode::RateLimit, "rate limit exceeded")
189    }
190
191    /// The scope's circuit breaker is open, so the SDK refused the call itself.
192    pub fn circuit_open() -> Error {
193        Error::new(ErrorCode::CircuitOpen, "circuit breaker is open")
194    }
195
196    /// The caller dropped the future before it finished — a `tokio::time::timeout` that
197    /// expired, a `select!` that took another branch. Nobody is waiting for this error: it
198    /// is what the operation hooks are told the call ended as, so the bookkeeping every
199    /// layer keeps per operation is closed out rather than left open.
200    ///
201    /// It is a [`ErrorCode::Network`] because that is what a call that never got an answer
202    /// is — and it counts against the scope's circuit breaker like any other, since a call
203    /// the caller had to give up waiting for says the same thing about HEY as one that
204    /// timed out on its own. It is not retryable: there is nobody left to answer.
205    pub fn cancelled() -> Error {
206        Error::new(ErrorCode::Network, "operation cancelled")
207    }
208
209    /// The scope already has as many calls in flight as its bulkhead allows.
210    pub fn bulkhead_full() -> Error {
211        Error::new(ErrorCode::BulkheadFull, "bulkhead is full")
212    }
213
214    pub fn network(source: impl std::error::Error + Send + Sync + 'static) -> Error {
215        Error::new(ErrorCode::Network, "Network error")
216            .with_hint(source.to_string())
217            .retryable()
218            .with_source(source)
219    }
220
221    pub fn api(status: u16, message: impl Into<String>) -> Error {
222        Error::new(ErrorCode::Api, message).with_status(status)
223    }
224
225    pub fn conflict(message: impl Into<String>) -> Error {
226        Error::new(ErrorCode::Conflict, message).with_status(409)
227    }
228
229    /// A body the client refused to read, because reading it whole is what the caller
230    /// would have gone on to do. It carries no HTTP status of its own: the answer never
231    /// arrived in full, so there is nothing to report about it but the refusal.
232    pub fn response_too_large(limit: usize, method: &Method, path: &str) -> Error {
233        Error {
234            response_too_large: true,
235            ..Error::api(
236                0,
237                format!("{method} {path}: response body exceeds {limit} bytes"),
238            )
239        }
240    }
241
242    /// Puts a refusal behind the error a status maps to, so a body too large to read on a
243    /// non-2xx answer still reports the status the answer carried.
244    pub(crate) fn refusing(mut self, refusal: Error) -> Error {
245        self.response_too_large = refusal.response_too_large;
246        if self.hint.is_none() {
247            self.hint = Some(refusal.to_string());
248        }
249        self.with_source(refusal)
250    }
251
252    /// The messages the model itself produced, joined. With none of them the error still
253    /// says something: "validation error".
254    pub fn validation(messages: &[String]) -> Error {
255        let mut message = messages.join("; ");
256        if message.is_empty() {
257            message = "validation error".to_string();
258        }
259        Error::new(ErrorCode::Validation, message).with_status(422)
260    }
261
262    /// A name that matched more than one record. Up to five matches are named in the hint;
263    /// beyond that the only useful advice is to narrow the search.
264    ///
265    /// Go renders the matches with `%v`, as `Did you mean: [Alice Bob]`. Here they read as
266    /// a list — `Did you mean: Alice, Bob` — which is what a Rust caller printing the hint
267    /// would expect.
268    pub fn ambiguous(resource: &str, matches: &[String]) -> Error {
269        let hint = match matches.len() {
270            1..=5 => format!("Did you mean: {}", matches.join(", ")),
271            _ => "Be more specific".to_string(),
272        };
273        Error::new(ErrorCode::Ambiguous, format!("Ambiguous {resource}")).with_hint(hint)
274    }
275
276    /// Wraps an error from outside the SDK as an API error that reads the way the original
277    /// did, for the callers that have to answer with this type and nothing better fits.
278    pub fn from_std(source: impl std::error::Error + Send + Sync + 'static) -> Error {
279        Error::new(ErrorCode::Api, source.to_string()).with_source(source)
280    }
281
282    /// Maps a non-2xx response onto the SDK's error vocabulary. The hint carries whatever
283    /// message the server put in the body, when it sent one, and the body itself is kept on
284    /// the error for a caller that needs more of it than a hint — see [`Error::body`].
285    pub fn from_response(
286        status: StatusCode,
287        method: &Method,
288        headers: &HeaderMap,
289        body: &[u8],
290    ) -> Error {
291        let error = match status.as_u16() {
292            401 => Error::auth("authentication required"),
293            403 if method != Method::GET => Error::forbidden_scope(),
294            403 => Error::forbidden("access denied"),
295            404 => Error::new(ErrorCode::NotFound, "resource not found").with_status(404),
296            422 => Error::new(ErrorCode::Validation, "validation error").with_status(422),
297            429 => Error::new(ErrorCode::RateLimit, "rate limited - try again later")
298                .with_hint(retry_hint(retry_after_seconds(headers)))
299                .with_status(429)
300                .retryable(),
301            code => {
302                let error = Error::api(code, format!("API error: {status}"));
303                if status.is_server_error() {
304                    error.retryable()
305                } else {
306                    error
307                }
308            }
309        };
310        let error = match headers
311            .get("x-request-id")
312            .and_then(|value| value.to_str().ok())
313        {
314            Some(request_id) => error.with_request_id(request_id),
315            None => error,
316        };
317        let mut error = match (error.hint.is_none(), server_message(body)) {
318            (true, Some(message)) => error.with_hint(message),
319            _ => error,
320        };
321        if !body.is_empty() {
322            let kept = body.len().min(MAX_ERROR_BODY_BYTES);
323            error.body = Some(Box::from(&body[..kept]));
324        }
325        error
326    }
327
328    pub fn with_hint(mut self, hint: impl Into<String>) -> Error {
329        self.hint = Some(hint.into());
330        self
331    }
332
333    pub fn with_status(mut self, status: u16) -> Error {
334        self.http_status = Some(status);
335        self
336    }
337
338    pub fn with_request_id(mut self, request_id: impl Into<String>) -> Error {
339        self.request_id = Some(request_id.into());
340        self
341    }
342
343    pub fn with_source(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Error {
344        self.source = Some(Box::new(source));
345        self
346    }
347
348    pub fn retryable(mut self) -> Error {
349        self.retryable = true;
350        self
351    }
352
353    pub fn code(&self) -> ErrorCode {
354        self.code
355    }
356
357    pub fn is_code(&self, code: ErrorCode) -> bool {
358        self.code == code
359    }
360
361    /// The process exit status a command should end with for this error.
362    pub fn exit_code(&self) -> i32 {
363        self.code.exit_code()
364    }
365
366    pub fn message(&self) -> &str {
367        &self.message
368    }
369
370    pub fn hint(&self) -> Option<&str> {
371        self.hint.as_deref()
372    }
373
374    pub fn http_status(&self) -> Option<u16> {
375        self.http_status
376    }
377
378    pub fn is_retryable(&self) -> bool {
379        self.retryable
380    }
381
382    pub fn request_id(&self) -> Option<&str> {
383        self.request_id.as_deref()
384    }
385
386    /// The answer was longer than the client will hold in memory, whether that refusal is
387    /// the error itself or sits behind the status the answer carried.
388    pub fn is_response_too_large(&self) -> bool {
389        self.response_too_large
390    }
391
392    /// What HEY answered the failure with, up to [`MAX_ERROR_BODY_BYTES`]. Several
393    /// endpoints describe a refusal in the body rather than in the status alone — the
394    /// contacts a clashing write collided with, the fields a 422 objected to — and this is
395    /// where that account is kept. It is `None` when the answer carried no body, when the
396    /// SDK raised the error itself, and when the body was too long to read.
397    pub fn body(&self) -> Option<&[u8]> {
398        self.body.as_deref()
399    }
400
401    /// The failure body read as `T`, or `None` when there is no body or it does not read as
402    /// one.
403    ///
404    /// ```no_run
405    /// # use hey_sdk::Error;
406    /// # use serde::Deserialize;
407    /// #[derive(Deserialize)]
408    /// struct Refusal {
409    ///     errors: Vec<String>,
410    /// }
411    ///
412    /// # fn report(error: &Error) {
413    /// if let Some(refusal) = error.body_json::<Refusal>() {
414    ///     println!("{}", refusal.errors.join("; "));
415    /// }
416    /// # }
417    /// ```
418    pub fn body_json<T: DeserializeOwned>(&self) -> Option<T> {
419        serde_json::from_slice(self.body()?).ok()
420    }
421}
422
423impl fmt::Display for Error {
424    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
425        match &self.hint {
426            Some(hint) => write!(f, "{}: {hint}", self.message),
427            None => f.write_str(&self.message),
428        }
429    }
430}
431
432impl std::error::Error for Error {
433    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
434        self.source
435            .as_deref()
436            .map(|source| source as &(dyn std::error::Error + 'static))
437    }
438}
439
440impl From<serde_json::Error> for Error {
441    fn from(error: serde_json::Error) -> Error {
442        Error::api(0, "unexpected JSON")
443            .with_hint(error.to_string())
444            .with_source(error)
445    }
446}
447
448impl From<url::ParseError> for Error {
449    fn from(error: url::ParseError) -> Error {
450        Error::usage(format!("invalid URL: {error}")).with_source(error)
451    }
452}
453
454fn retry_hint(retry_after: Option<u64>) -> String {
455    match retry_after {
456        Some(seconds) if seconds > 0 => format!("Try again in {seconds} seconds"),
457        _ => "Try again later".to_string(),
458    }
459}
460
461/// The wait `Retry-After` asks for, in seconds. The header carries either a count of
462/// seconds or the HTTP-date the wait is over, and a date already past asks for no wait at
463/// all.
464pub(crate) fn retry_after_seconds(headers: &HeaderMap) -> Option<u64> {
465    let asked = headers.get("retry-after")?.to_str().ok()?.trim();
466    match asked.parse::<i64>() {
467        Ok(seconds) => u64::try_from(seconds).ok(),
468        Err(_) => {
469            let until = chrono::DateTime::parse_from_rfc2822(asked).ok()?;
470            seconds_until(until.with_timezone(&chrono::Utc), chrono::Utc::now())
471        }
472    }
473}
474
475/// The whole seconds from `now` until `until`, counting a started second as one: the date
476/// names the moment the wait is over, so rounding down would resend before it.
477fn seconds_until(
478    until: chrono::DateTime<chrono::Utc>,
479    now: chrono::DateTime<chrono::Utc>,
480) -> Option<u64> {
481    let left = until.signed_duration_since(now);
482    let whole = left.num_seconds();
483    let started = left > chrono::Duration::seconds(whole);
484    u64::try_from(if started { whole + 1 } else { whole }).ok()
485}
486
487fn server_message(body: &[u8]) -> Option<String> {
488    let value: serde_json::Value = serde_json::from_slice(body).ok()?;
489    let message = value
490        .get("message")
491        .or_else(|| value.get("error"))?
492        .as_str()?;
493    Some(truncate(message, MAX_ERROR_MESSAGE_BYTES))
494}
495
496/// Cuts a message to `limit` characters, saying so with a trailing `...`, the way Go's
497/// `truncateString` does.
498pub(crate) fn truncate(message: &str, limit: usize) -> String {
499    if message.chars().count() <= limit {
500        message.to_string()
501    } else {
502        let kept: String = message.chars().take(limit - 3).collect();
503        format!("{kept}...")
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::seconds_until;
510    use chrono::{DateTime, Duration, Utc};
511
512    fn at(millis: i64) -> DateTime<Utc> {
513        DateTime::from_timestamp_millis(1_700_000_000_000 + millis).unwrap()
514    }
515
516    #[test]
517    fn a_started_second_counts_as_a_whole_one() {
518        assert_eq!(seconds_until(at(2_000), at(0)), Some(2));
519        assert_eq!(seconds_until(at(2_000), at(50)), Some(2));
520        assert_eq!(seconds_until(at(2_000), at(1_050)), Some(1));
521        assert_eq!(seconds_until(at(2_000), at(1_999)), Some(1));
522        assert_eq!(
523            seconds_until(at(2_000), at(0) - Duration::nanoseconds(1)),
524            Some(3)
525        );
526        assert_eq!(
527            seconds_until(at(2_000), at(2_000) - Duration::nanoseconds(1)),
528            Some(1)
529        );
530    }
531
532    #[test]
533    fn a_date_already_past_asks_for_no_wait() {
534        assert_eq!(seconds_until(at(0), at(0)), Some(0));
535        assert_eq!(seconds_until(at(0), at(500)), Some(0));
536        assert_eq!(seconds_until(at(0), at(1_500)), None);
537        assert_eq!(seconds_until(at(0) - Duration::hours(1), at(0)), None);
538    }
539}