just_common/error.rs
1//! Shared error types for the transport layer and provider clients.
2
3use std::string::FromUtf8Error;
4use thiserror::Error;
5
6use reqwest::StatusCode;
7
8/// Errors produced by the shared HTTP/SSE transport layer.
9#[derive(Debug, Error)]
10#[non_exhaustive]
11pub enum TransportError {
12 #[error("invalid configuration: {0}")]
13 InvalidConfig(&'static str),
14
15 #[error("failed to build http client: {0}")]
16 BuildClient(#[source] reqwest::Error),
17
18 #[error("request failed: {0}")]
19 Transport(#[source] reqwest::Error),
20
21 /// A non-success HTTP status, with the full response body captured as diagnostic text.
22 ///
23 /// `body` is read under the shared size cap (`MAX_BODY_BYTES`). Bodies that fit are captured
24 /// in full; an oversized error body instead surfaces as [`BodyTooLarge`](Self::BodyTooLarge)
25 /// and this variant is not produced. So when this variant is present, `body` is complete —
26 /// never a truncated prefix.
27 #[error("api returned {status}")]
28 HttpStatus { status: StatusCode, body: String },
29
30 /// A streamed response chunk could not be deserialized.
31 ///
32 /// Produced only by the SSE event parser, one event at a time. When a
33 /// `TransportError` is lifted into a [`ProviderError`] via `From`,
34 /// this surfaces as `ProviderError::Transport(TransportError::Deserialize)` —
35 /// **not** as `ProviderError::Deserialize`, which is reserved for full-body
36 /// failures produced by `parse_json`. Consumers matching for deserialization
37 /// failures across both paths must account for both variants.
38 #[error("failed to deserialize response body: {source}")]
39 Deserialize {
40 #[source]
41 source: serde_json::Error,
42 body: String,
43 },
44
45 #[error("failed to decode streamed response as UTF-8: {0}")]
46 Utf8(#[source] FromUtf8Error),
47
48 /// A non-streaming response body exceeded the shared size cap and was not fully buffered.
49 ///
50 /// Produced by the capped body reader (`read_body_text`). Distinct from
51 /// [`InvalidResponse`](Self::InvalidResponse), which reports *content* problems (empty body,
52 /// malformed structure) rather than a size limit. The reader stops before the offending chunk
53 /// is appended, so no body text is carried here. When the overflow occurs while reading an
54 /// *error* body, the HTTP status is not carried here either — it is visible only to callers
55 /// that read status off the raw response before consuming the body (the `prepare`/`send` path).
56 #[error("response body exceeded {limit}-byte limit")]
57 BodyTooLarge { limit: usize },
58
59 #[error("invalid response: {0}")]
60 InvalidResponse(String),
61}
62
63/// Generic error type for OpenAI-compatible API provider clients.
64#[derive(Debug, Error)]
65#[non_exhaustive]
66pub enum ProviderError {
67 /// Transport-layer error from the shared HTTP/SSE layer.
68 ///
69 /// This is also where streaming/chunk deserialization failures land: an SSE
70 /// event that fails to parse originates as `TransportError::Deserialize` and
71 /// is wrapped here, **not** as the [`Deserialize`](Self::Deserialize) variant
72 /// below, which is reserved for full response-body failures from `parse_json`.
73 #[error("transport error: {0}")]
74 Transport(#[from] TransportError),
75
76 /// The request shape was invalid for the selected client method.
77 #[error("invalid request: {0}")]
78 InvalidRequest(String),
79
80 /// Failed to serialize the request body.
81 #[error("serialization failed: {0}")]
82 Serialize(#[from] serde_json::Error),
83
84 /// Failed to deserialize a full response body.
85 ///
86 /// Produced only by `parse_json`, which deserializes the entire HTTP response
87 /// body. Streaming/chunk deserialization failures are a separate concern: they
88 /// originate as `TransportError::Deserialize` and surface on
89 /// [`Transport`](Self::Transport) (via `From<TransportError>`), not here.
90 #[error("failed to deserialize response body: {source}")]
91 Deserialize {
92 #[source]
93 source: serde_json::Error,
94 body: String,
95 },
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101 use std::error::Error as StdError;
102
103 /// After dropping `#[error(transparent)]` from `ProviderError::Transport`, the wrapped
104 /// `TransportError` must be reachable by walking `source()` and downcasting — so a consumer
105 /// can recover, e.g. a 429 status from the error object. This contract would have failed
106 /// before the change: `transparent` flattened `TransportError` out of the source chain.
107 #[test]
108 fn transport_error_reachable_via_source_chain() {
109 let te = TransportError::HttpStatus {
110 status: StatusCode::TOO_MANY_REQUESTS,
111 body: "rate limited".into(),
112 };
113 let pe: ProviderError = te.into(); // ProviderError::Transport(te)
114
115 let mut cur: Option<&(dyn StdError + 'static)> = Some(&pe);
116 let mut found = None;
117 while let Some(e) = cur {
118 if let Some(t) = e.downcast_ref::<TransportError>() {
119 found = Some(t);
120 break;
121 }
122 cur = e.source();
123 }
124
125 let found = found.expect("TransportError must be reachable via the source chain");
126 assert!(
127 matches!(
128 found,
129 TransportError::HttpStatus { status, .. } if *status == StatusCode::TOO_MANY_REQUESTS
130 ),
131 "expected HttpStatus 429, got {found:?}"
132 );
133 }
134}