Skip to main content

origin_http_reqwest/
lib.rs

1//! `reqwest`-backed implementation of [`origin_http::HttpClient`].
2//!
3//! One instance per application: `reqwest::Client` owns the connection pool, so
4//! constructing several defeats keep-alive and multiplies open sockets.
5
6use async_trait::async_trait;
7use origin_domain::{AppError, Result};
8use origin_http::{Headers, HttpClient, HttpRequest, HttpResponse};
9use std::time::Duration;
10
11/// Default overall request timeout. Long enough for a slow paginated call, short
12/// enough that a hung connection cannot stall a sync run indefinitely.
13const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
14
15/// Default connect timeout. A separate, much shorter budget, so an unreachable host
16/// fails fast instead of consuming the whole request timeout.
17const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
18
19/// Default ceiling on a response body. Comfortably large for any JSON API response
20/// Origin talks to; small enough that a runaway or hostile server cannot exhaust the
21/// desktop process's memory reading an unbounded or falsely-labelled body.
22const DEFAULT_MAX_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
23
24#[derive(Debug, Clone)]
25pub struct ReqwestHttpClient {
26    inner: reqwest::Client,
27    max_response_bytes: u64,
28}
29
30impl ReqwestHttpClient {
31    /// Build a client identified by `user_agent`.
32    ///
33    /// Several APIs reject or throttle requests without a meaningful user agent, so it
34    /// is required rather than optional.
35    pub fn new(user_agent: impl AsRef<str>) -> Result<Self> {
36        Self::builder(user_agent).build()
37    }
38
39    pub fn builder(user_agent: impl AsRef<str>) -> ReqwestHttpClientBuilder {
40        ReqwestHttpClientBuilder {
41            user_agent: user_agent.as_ref().to_owned(),
42            timeout: DEFAULT_TIMEOUT,
43            connect_timeout: DEFAULT_CONNECT_TIMEOUT,
44            max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
45        }
46    }
47}
48
49#[derive(Debug, Clone)]
50pub struct ReqwestHttpClientBuilder {
51    user_agent: String,
52    timeout: Duration,
53    connect_timeout: Duration,
54    max_response_bytes: u64,
55}
56
57impl ReqwestHttpClientBuilder {
58    pub fn timeout(mut self, timeout: Duration) -> Self {
59        self.timeout = timeout;
60        self
61    }
62
63    pub fn connect_timeout(mut self, connect_timeout: Duration) -> Self {
64        self.connect_timeout = connect_timeout;
65        self
66    }
67
68    /// Override the response body ceiling (default 10 MiB). A connector whose provider
69    /// legitimately answers with larger payloads sets this explicitly and visibly,
70    /// rather than the port having no limit at all.
71    pub fn max_response_bytes(mut self, max_response_bytes: u64) -> Self {
72        self.max_response_bytes = max_response_bytes;
73        self
74    }
75
76    pub fn build(self) -> Result<ReqwestHttpClient> {
77        let inner = reqwest::Client::builder()
78            .user_agent(self.user_agent)
79            .timeout(self.timeout)
80            .connect_timeout(self.connect_timeout)
81            .build()
82            .map_err(|error| {
83                AppError::configuration(format!("cannot build http client: {error}"))
84            })?;
85
86        Ok(ReqwestHttpClient {
87            inner,
88            max_response_bytes: self.max_response_bytes,
89        })
90    }
91}
92
93#[async_trait]
94impl HttpClient for ReqwestHttpClient {
95    async fn send(&self, request: HttpRequest) -> Result<HttpResponse> {
96        let method = reqwest::Method::from_bytes(request.method.as_str().as_bytes())
97            .map_err(|error| AppError::internal(format!("invalid http method: {error}")))?;
98
99        // The URL is logged with its query string stripped — a query can carry an API
100        // key or an OAuth code — and headers are not logged at all; `Headers` only
101        // redacts when something formats it with `Debug`.
102        tracing::debug!(
103            method = %request.method,
104            url = %request.url.split('?').next().unwrap_or(&request.url),
105            "http request"
106        );
107
108        let mut builder = self.inner.request(method, &request.url);
109        for (name, value) in request.headers.iter() {
110            builder = builder.header(name, value);
111        }
112        if let Some(body) = request.body {
113            builder = builder.body(body);
114        }
115
116        let response = builder.send().await.map_err(to_app_error)?;
117
118        let status = response.status().as_u16();
119        let headers = response
120            .headers()
121            .iter()
122            .filter_map(|(name, value)| {
123                // A header we cannot read as text is dropped rather than fatal: it is
124                // never one we route on.
125                value
126                    .to_str()
127                    .ok()
128                    .map(|value| (name.as_str().to_owned(), value.to_owned()))
129            })
130            .collect::<Headers>();
131
132        let body = read_body_limited(response, self.max_response_bytes).await?;
133
134        tracing::debug!(status, bytes = body.len(), "http response");
135        Ok(HttpResponse::new(status, headers, body))
136    }
137}
138
139/// Reads a response body up to `limit` bytes, failing rather than buffering further.
140///
141/// A `Content-Length` header is checked first so an honestly-labelled oversized
142/// response fails before any of it is read; the running total during the read guards
143/// against a response with no `Content-Length` or one that undercounts it.
144async fn read_body_limited(mut response: reqwest::Response, limit: u64) -> Result<Vec<u8>> {
145    if let Some(length) = response.content_length()
146        && length > limit
147    {
148        return Err(AppError::ExternalService(format!(
149            "response declared {length} bytes, over the {limit} byte limit"
150        )));
151    }
152
153    let mut body = Vec::new();
154    while let Some(chunk) = response.chunk().await.map_err(to_app_error)? {
155        if body.len() as u64 + chunk.len() as u64 > limit {
156            return Err(AppError::ExternalService(format!(
157                "response body exceeds the {limit} byte limit"
158            )));
159        }
160        body.extend_from_slice(&chunk);
161    }
162    Ok(body)
163}
164
165/// Transport failures only — a non-2xx status is not an error here (see [`HttpClient`]).
166///
167/// The offline/network distinction matters: `Offline` tells the sync engine to wait for
168/// connectivity, while `Network` means retry with backoff.
169fn to_app_error(error: reqwest::Error) -> AppError {
170    if error.is_timeout() {
171        return AppError::Network(format!("request timed out: {error}"));
172    }
173
174    if error.is_connect() {
175        return AppError::Offline(format!("cannot reach host: {error}"));
176    }
177
178    AppError::Network(error.to_string())
179}