Skip to main content

rig_http/http_client/
mod.rs

1//! Transport-independent HTTP requests, lazy response bodies, and errors.
2//!
3//! ```
4//! use rig_http::http_client::{NoBody, Request};
5//!
6//! let request = Request::get("https://example.com/v1/models").body(NoBody)?;
7//! assert_eq!(request.uri().path(), "/v1/models");
8//! # Ok::<(), rig_http::http_client::Error>(())
9//! ```
10
11pub use bytes::Bytes;
12pub use http::{
13    HeaderMap, HeaderValue, Method, Request, Response, StatusCode, Uri, request::Builder,
14};
15mod erased;
16pub mod framing;
17pub mod middleware;
18pub mod multipart;
19use crate::wasm_compat::*;
20pub use erased::DynHttpClient;
21pub use middleware::{Exchange, HttpMiddleware};
22pub use multipart::MultipartForm;
23
24/// Why an HTTP request failed: a protocol or header error, a non-success reply, the
25/// transport's own error, or a client that could not be built.
26#[derive(Debug, thiserror::Error)]
27#[non_exhaustive]
28pub enum Error {
29    /// The request could not be built.
30    #[error("Http error: {0}")]
31    Protocol(#[from] http::Error),
32    /// Failed HTTP response preserving its status, body, and headers.
33    /// Transport failures without a response use [`Self::Instance`], a
34    /// protocol variant, or [`Self::Unbuilt`] when no client could be built.
35    #[error("Invalid status code {status} with message: {body}")]
36    InvalidStatusCodeWithDetails {
37        /// The non-success status.
38        status: StatusCode,
39        /// The raw response body.
40        body: String,
41        /// The failed response's headers, verbatim.
42        headers: http::HeaderMap,
43    },
44    /// A header value with characters a header cannot carry.
45    #[error("Header value outside of legal range: {0}")]
46    InvalidHeaderValue(#[from] http::header::InvalidHeaderValue),
47    /// The request builder failed earlier, so its headers cannot be read.
48    #[error("Request in error state, cannot access headers")]
49    NoHeaders,
50    /// The response stream ended before it was done.
51    #[error("Stream ended")]
52    StreamEnded,
53    /// The response's content type was not the one expected.
54    #[error("Invalid content type was returned: {0:?}")]
55    InvalidContentType(HeaderValue),
56    /// The transport's own error, without a response.
57    #[cfg(not(target_family = "wasm"))]
58    #[error("Http client error: {0}")]
59    Instance(#[from] Box<dyn std::error::Error + Send + Sync + 'static>),
60
61    /// The transport's own error, without a response.
62    #[cfg(target_family = "wasm")]
63    #[error("Http client error: {0}")]
64    Instance(#[from] Box<dyn std::error::Error + 'static>),
65
66    /// The transport's client could not be built, so nothing was sent.
67    /// Sending again fails the same way.
68    #[cfg(not(target_family = "wasm"))]
69    #[error("Http client unavailable: {0}")]
70    Unbuilt(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
71
72    /// The transport's client could not be built, so nothing was sent.
73    /// Sending again fails the same way.
74    #[cfg(target_family = "wasm")]
75    #[error("Http client unavailable: {0}")]
76    Unbuilt(#[source] Box<dyn std::error::Error + 'static>),
77}
78
79impl Error {
80    /// Preserved HTTP failure status, or `None` for other error variants.
81    pub fn non_success_status(&self) -> Option<StatusCode> {
82        match self {
83            Self::InvalidStatusCodeWithDetails { status, .. } => Some(*status),
84            _ => None,
85        }
86    }
87
88    /// The response body this error preserved, when it has one. Companion to
89    /// [`Self::non_success_status`] and [`Self::non_success_headers`].
90    pub fn non_success_body(&self) -> Option<&str> {
91        match self {
92            Self::InvalidStatusCodeWithDetails { body, .. } => Some(body.as_str()),
93            _ => None,
94        }
95    }
96
97    /// Constructs a failure from its HTTP status, headers, and body without
98    /// validating that the status is non-successful.
99    pub fn non_success_with_details(status: StatusCode, headers: HeaderMap, body: String) -> Self {
100        Self::InvalidStatusCodeWithDetails {
101            status,
102            body,
103            headers,
104        }
105    }
106
107    /// Returns the failed response's headers, when this error preserved them.
108    ///
109    /// The following example reads the seconds form of `Retry-After`:
110    ///
111    /// ```
112    /// # use rig_http::http_client::Error;
113    /// # use std::time::Duration;
114    /// fn retry_after(error: &Error) -> Option<Duration> {
115    ///     let seconds = error
116    ///         .non_success_headers()?
117    ///         .get(http::header::RETRY_AFTER)?
118    ///         .to_str()
119    ///         .ok()?
120    ///         .parse()
121    ///         .ok()?;
122    ///     Some(Duration::from_secs(seconds))
123    /// }
124    /// ```
125    ///
126    /// Returns `None` for a response-less failure: every non-success error
127    /// carries its headers.
128    pub fn non_success_headers(&self) -> Option<&HeaderMap> {
129        match self {
130            Self::InvalidStatusCodeWithDetails { headers, .. } => Some(headers),
131            _ => None,
132        }
133    }
134}
135
136/// A result with an HTTP [`Error`].
137pub type Result<T> = std::result::Result<T, Error>;
138
139impl Error {
140    /// Wrap a transport's native error as [`Error::Instance`]. Transports use
141    /// this for response-less failures (connect, decode, timeout); non-success
142    /// responses go through [`Error::non_success_with_details`] instead so the
143    /// status stays inspectable.
144    #[cfg(not(target_family = "wasm"))]
145    pub fn instance<E: std::error::Error + Send + Sync + 'static>(error: E) -> Self {
146        Self::Instance(error.into())
147    }
148
149    /// Wrap a transport's native error as [`Error::Instance`].
150    #[cfg(target_family = "wasm")]
151    pub fn instance<E: std::error::Error + 'static>(error: E) -> Self {
152        Self::Instance(error.into())
153    }
154
155    /// Wrap why a transport's client could not be built as
156    /// [`Error::Unbuilt`].
157    #[cfg(not(target_family = "wasm"))]
158    pub fn unbuilt<E: std::error::Error + Send + Sync + 'static>(error: E) -> Self {
159        Self::Unbuilt(error.into())
160    }
161
162    /// Wrap why a transport's client could not be built as
163    /// [`Error::Unbuilt`].
164    #[cfg(target_family = "wasm")]
165    pub fn unbuilt<E: std::error::Error + 'static>(error: E) -> Self {
166        Self::Unbuilt(error.into())
167    }
168}
169
170/// A response body that is read when awaited.
171pub type LazyBody<T> = WasmBoxedFuture<'static, Result<T>>;
172
173/// The body of a streaming response: the transport's own chunks, boxed.
174pub type BoxedStream = std::pin::Pin<Box<dyn WasmCompatSendStream<InnerItem = Result<Bytes>>>>;
175
176/// A response whose body streams.
177pub type StreamingResponse = Response<BoxedStream>;
178
179/// A request with an empty body.
180#[derive(Debug, Clone, Copy)]
181pub struct NoBody;
182
183impl From<NoBody> for Bytes {
184    fn from(_: NoBody) -> Self {
185        Bytes::new()
186    }
187}
188
189/// Reads `response`'s body as text, replacing invalid UTF-8.
190pub async fn text(response: Response<LazyBody<Vec<u8>>>) -> Result<String> {
191    let text = response.into_body().await?;
192    Ok(String::from(String::from_utf8_lossy(&text)))
193}
194
195/// An HTTP client Rig sends through: unary, multipart and streaming requests.
196///
197/// `rig-reqwest`'s `ReqwestClient` is the bundled implementation; a provider
198/// configuration takes any implementation with `connect(client)`.
199#[diagnostic::on_unimplemented(
200    message = "`{Self}` is not an HTTP client Rig can send through",
201    label = "not an `HttpClientExt`",
202    note = "use `rig_reqwest::ReqwestClient` (wrap a configured `reqwest::Client` with `ReqwestClient::from(client)`), erase a client with `DynHttpClient::new(client)`, or implement `HttpClientExt`; a provider configuration takes it with `.connect(client)`"
203)]
204pub trait HttpClientExt: WasmCompatSend + WasmCompatSync {
205    /// Sends a request and returns headers with a lazy body converted from bytes to `U`.
206    fn send<T, U>(
207        &self,
208        req: Request<T>,
209    ) -> impl Future<Output = Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
210    where
211        T: Into<Bytes>,
212        T: WasmCompatSend,
213        U: From<Bytes>,
214        U: WasmCompatSend + 'static;
215
216    /// Sends a multipart request and returns headers with a lazy body converted to `U`.
217    fn send_multipart<U>(
218        &self,
219        req: Request<MultipartForm>,
220    ) -> impl Future<Output = Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
221    where
222        U: From<Bytes>,
223        U: WasmCompatSend + 'static;
224
225    /// Sends a request and returns headers with a stream of transport byte chunks.
226    fn send_streaming<T>(
227        &self,
228        req: Request<T>,
229    ) -> impl Future<Output = Result<StreamingResponse>> + WasmCompatSend
230    where
231        T: Into<Bytes> + WasmCompatSend;
232}
233
234#[cfg(test)]
235mod non_success_header_tests;