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