use std::fmt::Display;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use bytes::Bytes;
use serde::de::DeserializeOwned;
use tokio::sync::Mutex;
use url::Url;
use crate::auth::{AuthStrategy, BearerAuth, TokenProvider};
use crate::cache::{CachedResponse, FileCache, ResponseCache, cache_key};
use crate::config::Config;
use crate::error::{Error, ErrorCode, retry_after_seconds};
use crate::http::header::{
ACCEPT, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, COOKIE, IF_NONE_MATCH,
PROXY_AUTHORIZATION, USER_AGENT,
};
use crate::http::{
Body, HeaderMap, HeaderValue, HttpClient, Method, Request, Response as HttpResponse, StatusCode,
};
use crate::observability::{
Hooks, NoopHooks, OperationInfo, OperationState, RequestInfo, RequestResult,
};
use crate::operation::Operation;
use crate::pagination::Page;
use crate::route::Route;
use crate::security::{is_same_origin, require_secure_endpoint};
use crate::services::boxes::BoxKinds;
#[cfg(feature = "tracing")]
use crate::trace::label;
use crate::trace::{AttemptSpan, OperationSpan};
use crate::version::default_user_agent;
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
pub const DEFAULT_MAX_RETRIES: u32 = 3;
pub const DEFAULT_BASE_DELAY: Duration = Duration::from_secs(1);
pub const DEFAULT_MAX_DELAY: Duration = Duration::from_secs(30);
pub const DEFAULT_MAX_JITTER: Duration = Duration::from_millis(100);
pub const DEFAULT_MAX_PAGES: usize = 10_000;
pub const DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 16 << 20;
pub const MAX_RESPONSE_BODY_BYTES: usize = 50 << 20;
const RETRYABLE_STATUSES: &[u16] = &[429, 500, 502, 503, 504];
const ACCOUNT_FILTER_PARAMETER: &str = "filtered_account_id";
const MAX_REDIRECTS: usize = 10;
tokio::task_local! {
static DEADLINE: Option<Instant>;
}
#[derive(Clone)]
pub struct Client {
pub(crate) shared: Arc<Shared>,
pub(crate) account_id: Option<i64>,
pub(crate) scope: Arc<ScopeState>,
}
pub(crate) struct Shared {
pub(crate) config: Config,
pub(crate) base_url: Url,
pub(crate) http: Arc<dyn HttpClient>,
pub(crate) auth: Arc<dyn AuthStrategy>,
pub(crate) user_agent: String,
pub(crate) max_retries: u32,
pub(crate) base_delay: Option<Duration>,
pub(crate) max_delay: Duration,
pub(crate) max_jitter: Duration,
pub(crate) max_pages: usize,
pub(crate) max_response_body_bytes: usize,
pub(crate) cache: Option<Arc<dyn ResponseCache>>,
pub(crate) hooks: Arc<dyn Hooks>,
pub(crate) operation_timeout: Option<Duration>,
pub(crate) refreshes: AtomicU64,
pub(crate) refreshing: tokio::sync::RwLock<()>,
}
#[derive(Default)]
pub(crate) struct ScopeState {
pub(crate) default_sender_id: Mutex<Option<i64>>,
pub(crate) account_user_id: Mutex<Option<i64>>,
pub(crate) box_kinds: Mutex<Option<BoxKinds>>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Response {
pub status: StatusCode,
pub headers: HeaderMap,
pub body: Bytes,
pub url: Url,
pub from_cache: bool,
pub empty: bool,
}
impl Response {
pub fn json<T: DeserializeOwned>(&self) -> Result<T, Error> {
if self.body.is_empty() {
let error = Error::api(self.status.as_u16(), "empty response body");
Err(match self.header("x-request-id") {
Some(request_id) => error.with_request_id(request_id),
None => error,
})
} else {
serde_json::from_slice(&self.body).map_err(|error| {
Error::decoding(self.status.as_u16(), self.header("x-request-id"), error)
})
}
}
pub fn header(&self, name: &str) -> Option<&str> {
self.headers.get(name).and_then(|value| value.to_str().ok())
}
}
pub struct ClientBuilder {
config: Config,
auth: Option<Arc<dyn AuthStrategy>>,
http: Option<Arc<dyn HttpClient>>,
user_agent: String,
timeout: Duration,
max_retries: u32,
base_delay: Option<Duration>,
max_delay: Duration,
max_jitter: Duration,
max_pages: usize,
max_response_body_bytes: usize,
cache: Option<Arc<dyn ResponseCache>>,
pub(crate) hooks: Arc<dyn Hooks>,
operation_timeout: Option<Duration>,
}
impl ClientBuilder {
pub fn new(config: Config) -> ClientBuilder {
ClientBuilder {
config,
auth: None,
http: None,
user_agent: default_user_agent(),
timeout: DEFAULT_TIMEOUT,
max_retries: DEFAULT_MAX_RETRIES,
base_delay: None,
max_delay: DEFAULT_MAX_DELAY,
max_jitter: DEFAULT_MAX_JITTER,
max_pages: DEFAULT_MAX_PAGES,
max_response_body_bytes: DEFAULT_MAX_RESPONSE_BODY_BYTES,
cache: None,
hooks: Arc::new(NoopHooks),
operation_timeout: None,
}
}
#[must_use]
pub fn token_provider(self, provider: impl TokenProvider + 'static) -> ClientBuilder {
self.auth_strategy(BearerAuth::new(provider))
}
#[must_use]
pub fn auth_strategy(mut self, strategy: impl AuthStrategy + 'static) -> ClientBuilder {
self.auth = Some(Arc::new(strategy));
self
}
#[must_use]
pub fn http_client(mut self, http: impl HttpClient + 'static) -> ClientBuilder {
self.http = Some(Arc::new(http));
self
}
#[must_use]
pub fn user_agent(mut self, user_agent: impl Into<String>) -> ClientBuilder {
self.user_agent = user_agent.into();
self
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> ClientBuilder {
self.timeout = timeout;
self
}
#[must_use]
pub fn operation_timeout(mut self, limit: Duration) -> ClientBuilder {
self.operation_timeout = Some(limit);
self
}
#[must_use]
pub fn max_retries(mut self, max_retries: u32) -> ClientBuilder {
self.max_retries = max_retries;
self
}
#[must_use]
pub fn base_delay(mut self, base_delay: Duration) -> ClientBuilder {
self.base_delay = Some(base_delay);
self
}
#[must_use]
pub fn max_delay(mut self, max_delay: Duration) -> ClientBuilder {
self.max_delay = max_delay;
self
}
#[must_use]
pub fn max_jitter(mut self, max_jitter: Duration) -> ClientBuilder {
self.max_jitter = max_jitter;
self
}
#[must_use]
pub fn max_pages(mut self, max_pages: usize) -> ClientBuilder {
self.max_pages = max_pages;
self
}
#[must_use]
pub fn max_response_body_bytes(mut self, bytes: usize) -> ClientBuilder {
self.max_response_body_bytes = bytes;
self
}
#[must_use]
pub fn cache(mut self, cache: impl ResponseCache + 'static) -> ClientBuilder {
self.cache = Some(Arc::new(cache));
self
}
#[must_use]
pub fn hooks(mut self, hooks: impl Hooks + 'static) -> ClientBuilder {
self.hooks = Arc::new(hooks);
self
}
pub fn build(self) -> Result<Client, Error> {
let base_url = parse_base_url(&self.config.base_url)?;
let auth = self
.auth
.ok_or_else(|| Error::usage("a token provider or auth strategy is required"))?;
if self.timeout.is_zero() {
return Err(Error::usage("timeout must be greater than zero"));
}
if self.max_pages == 0 {
return Err(Error::usage("max pages must be greater than zero"));
}
if self.operation_timeout.is_some_and(|limit| limit.is_zero()) {
return Err(Error::usage("operation timeout must be greater than zero"));
}
if self
.operation_timeout
.is_some_and(|limit| Instant::now().checked_add(limit).is_none())
{
return Err(Error::usage(
"operation timeout is too long to keep time by",
));
}
let http = match self.http {
Some(http) => http,
None => shipped_http_client(self.timeout)?,
};
let cache =
match (self.cache, self.config.cache_enabled) {
(Some(cache), _) => Some(cache),
(None, true) => Some(Arc::new(FileCache::new(self.config.cache_dir.clone()))
as Arc<dyn ResponseCache>),
(None, false) => None,
};
let max_response_body_bytes = match self.max_response_body_bytes {
0 => DEFAULT_MAX_RESPONSE_BODY_BYTES,
bytes => bytes,
};
let shared = Shared {
config: self.config,
base_url,
http,
auth,
user_agent: self.user_agent,
max_retries: self.max_retries,
base_delay: self.base_delay,
max_delay: self.max_delay,
max_jitter: self.max_jitter,
max_pages: self.max_pages,
max_response_body_bytes,
cache,
hooks: self.hooks,
operation_timeout: self.operation_timeout,
refreshes: AtomicU64::new(0),
refreshing: tokio::sync::RwLock::new(()),
};
Ok(Client {
shared: Arc::new(shared),
account_id: None,
scope: Arc::default(),
})
}
}
impl Client {
pub fn builder(config: Config) -> ClientBuilder {
ClientBuilder::new(config)
}
#[cfg(feature = "reqwest")]
#[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
pub fn new(config: Config, provider: impl TokenProvider + 'static) -> Result<Client, Error> {
Client::builder(config).token_provider(provider).build()
}
pub fn config(&self) -> &Config {
&self.shared.config
}
pub fn base_url(&self) -> &Url {
&self.shared.base_url
}
pub fn account_id(&self) -> Option<i64> {
self.account_id
}
pub fn max_pages(&self) -> usize {
self.shared.max_pages
}
pub(crate) fn http(&self) -> &dyn HttpClient {
self.shared.http.as_ref()
}
pub fn operation(&self, route: &'static Route, params: &[&dyn Display]) -> Operation {
Operation::for_route(route, params)
}
pub fn request(&self, method: Method, path: impl Into<String>) -> Operation {
Operation::raw(method, path.into())
}
pub async fn send<T: DeserializeOwned>(&self, operation: Operation) -> Result<T, Error> {
let label = operation.label().to_string();
self.execute(operation)
.await?
.json()
.map_err(|error| error.about(&label))
}
pub async fn send_unit(&self, operation: Operation) -> Result<(), Error> {
self.execute(operation).await.map(|_| ())
}
pub async fn send_text(&self, operation: Operation) -> Result<String, Error> {
let response = self.execute(operation).await?;
Ok(String::from_utf8_lossy(&response.body).into_owned())
}
pub async fn send_optional<T: DeserializeOwned>(
&self,
operation: Operation,
) -> Result<Option<T>, Error> {
let label = operation.label().to_string();
let response = self.execute(operation).await?;
if response.empty {
Ok(None)
} else {
response
.json()
.map(Some)
.map_err(|error| error.about(&label))
}
}
pub async fn send_page<T: DeserializeOwned>(
&self,
operation: Operation,
) -> Result<Page<T>, Error> {
let label = operation.label().to_string();
let info = operation.info.clone();
let route = operation.route;
let response = self.execute(operation).await?;
let value = response.json().map_err(|error| error.about(&label))?;
Ok(Page::new(value, &response, info, route))
}
pub async fn next_page<T: DeserializeOwned>(
&self,
page: &Page<T>,
) -> Result<Option<Page<T>>, Error> {
match page.next_url() {
None => Ok(None),
Some(next) if !is_same_origin(next, &self.shared.base_url) => Err(Error::usage(
format!("pagination Link header points to a different origin: {next}"),
)),
Some(next) => {
let mut operation = Operation::at(Method::GET, next.clone());
operation.info(page.info().clone());
operation.route = page.route();
self.send_page(operation).await.map(Some)
}
}
}
pub async fn each_page<T: DeserializeOwned>(
&self,
first: Page<T>,
mut visit: impl FnMut(&Page<T>) -> bool,
) -> Result<(), Error> {
self.within_limit(Box::pin(async move {
let mut page = first;
let mut count = 1;
while visit(&page) {
if !page.has_next() {
break;
}
if count >= self.shared.max_pages {
return Err(Error::pagination_capped(self.shared.max_pages));
}
match self.next_page(&page).await? {
Some(next) => page = next,
None => break,
}
count += 1;
}
Ok(())
}))
.await
}
pub async fn execute(&self, operation: Operation) -> Result<Response, Error> {
let deadline = self.deadline();
let span = span_for(&operation);
span.wrap(self.instrument(&operation, deadline, self.dispatch(&operation, &span)))
.await
}
pub(crate) async fn stream(
&self,
operation: Operation,
deadline: Option<Instant>,
) -> Result<HttpResponse<Body>, Error> {
let span = span_for(&operation);
span.wrap(self.instrument(&operation, deadline, self.streamed(&operation, &span)))
.await
}
pub(crate) fn deadline(&self) -> Option<Instant> {
match DEADLINE.try_with(|deadline| *deadline) {
Ok(inherited) => inherited,
Err(_) => self
.shared
.operation_timeout
.and_then(|limit| Instant::now().checked_add(limit)),
}
}
pub(crate) async fn within_limit<T>(
&self,
work: impl Future<Output = Result<T, Error>>,
) -> Result<T, Error> {
let deadline = self.deadline();
DEADLINE
.scope(deadline, self.within_deadline(deadline, work))
.await
}
pub(crate) async fn within_deadline<T>(
&self,
deadline: Option<Instant>,
work: impl Future<Output = Result<T, Error>>,
) -> Result<T, Error> {
match (deadline, self.shared.operation_timeout) {
(Some(deadline), Some(limit)) => {
match tokio::time::timeout_at(deadline.into(), work).await {
Ok(outcome) => outcome,
Err(_) => Err(Error::timed_out(limit)),
}
}
_ => work.await,
}
}
async fn instrument<T>(
&self,
operation: &Operation,
deadline: Option<Instant>,
work: impl Future<Output = Result<T, Error>>,
) -> Result<T, Error> {
if operation.quiet {
self.within_deadline(deadline, work).await
} else {
let hooks = &self.shared.hooks;
self.within_deadline(deadline, hooks.on_operation_gate(&operation.info))
.await?;
let mut running = Running {
hooks,
info: &operation.info,
state: Some(hooks.on_operation_start(&operation.info)),
started: Instant::now(),
};
let outcome = self.within_deadline(deadline, work).await;
running.finished(outcome.as_ref().map(|_| ()));
outcome
}
}
async fn dispatch(
&self,
operation: &Operation,
span: &OperationSpan,
) -> Result<Response, Error> {
let url = self.url_for(operation)?;
let mut answered = self.attempt(operation, &url).await?;
let status = answered.response.status();
span.answered(status, request_id(answered.response.headers()));
let finished = self
.finish(
operation,
&url,
answered.url,
answered.response,
answered.cached,
)
.await;
answered.sending.end(&RequestResult {
status: Some(status),
duration: answered.duration,
error: finished.as_ref().err(),
from_cache: finished.as_ref().is_ok_and(|response| response.from_cache),
retryable: answered.retryable,
retry_after: answered.retry_after,
});
finished
}
async fn streamed(
&self,
operation: &Operation,
span: &OperationSpan,
) -> Result<HttpResponse<Body>, Error> {
let url = self.url_for(operation)?;
let mut answered = self.attempt(operation, &url).await?;
let status = answered.response.status();
span.answered(status, request_id(answered.response.headers()));
let failure = (!status.is_success()).then(|| {
Error::from_response(status, &operation.method, answered.response.headers(), &[])
});
answered.sending.end(&RequestResult {
status: Some(status),
duration: answered.duration,
error: failure.as_ref(),
from_cache: false,
retryable: answered.retryable,
retry_after: answered.retry_after,
});
match failure {
Some(error) => Err(error),
None => Ok(answered.response),
}
}
fn budget(&self, operation: &Operation) -> Budget {
let shared = &self.shared;
let ceiling = shared.max_retries.saturating_add(1);
let (attempts, retry_on, delay) = match operation.route.map(|route| &route.retry) {
Some(policy) if policy.max > 0 => (
policy.max.min(ceiling),
policy.retry_on,
Duration::from_millis(policy.base_delay_ms)
.max(shared.base_delay.unwrap_or(Duration::ZERO)),
),
Some(_) => (1, &[][..], DEFAULT_BASE_DELAY),
None => (
ceiling,
RETRYABLE_STATUSES,
shared.base_delay.unwrap_or(DEFAULT_BASE_DELAY),
),
};
Budget {
attempts: if operation.idempotent { attempts } else { 1 },
retry_on,
delay: delay.min(shared.max_delay),
}
}
#[allow(clippy::too_many_lines)] async fn attempt(&self, operation: &Operation, url: &Url) -> Result<Answered, Error> {
let hooks = &self.shared.hooks;
let budget = self.budget(operation);
let mut attempts = budget.attempts;
let mut attempt = 1;
let mut delay = budget.delay;
let mut refreshed = false;
let mut cached = None;
loop {
let (request, signed_under) = {
let _signing = self.shared.refreshing.read().await;
let request = self.prepare(operation, url, &mut cached).await?;
(request, self.shared.refreshes.load(Ordering::Acquire))
};
let mut sending = Sending::start(
hooks.clone(),
RequestInfo {
method: operation.method.clone(),
url: url.clone(),
attempt,
},
);
let started = Instant::now();
let sent = {
let span = AttemptSpan::new(attempt);
let sent = span
.wrap(self.transmit(operation, url.clone(), request))
.await;
if let Ok((_, response)) = &sent {
span.answered(response.status());
}
sent
};
let duration = started.elapsed();
match sent {
Err(error) => {
sending.end(&RequestResult {
status: None,
duration,
error: Some(&error),
from_cache: false,
retryable: true,
retry_after: None,
});
if attempt < attempts {
crate::trace::debug!(operation = label(operation), attempt, error = %error.code(), "request failed, retrying");
hooks.on_retry(&sending.info, attempt + 1, &error);
self.wait(delay).await;
delay = self.next_delay(delay);
attempt += 1;
} else {
return Err(error);
}
}
Ok((final_url, response)) => {
let status = response.status();
let retryable = budget.retry_on.contains(&status.as_u16());
let retry_after = retry_after_asked(status, response.headers());
if status == StatusCode::UNAUTHORIZED
&& !refreshed
&& self.refresh_credentials(signed_under).await
{
let cause = Error::auth("Token refreshed").retryable();
sending.end(&RequestResult {
status: Some(status),
duration,
error: Some(&cause),
from_cache: false,
retryable,
retry_after,
});
crate::trace::debug!(
operation = label(operation),
"credentials refreshed, resending"
);
hooks.on_retry(&sending.info, attempt + 1, &cause);
refreshed = true;
attempt += 1;
attempts = attempts.max(attempt);
} else if retryable && attempt < attempts {
let cause = Error::from_response(
status,
&operation.method,
response.headers(),
&[],
);
sending.end(&RequestResult {
status: Some(status),
duration,
error: Some(&cause),
from_cache: false,
retryable,
retry_after,
});
crate::trace::debug!(operation = label(operation), attempt, %status, "retryable status, retrying");
hooks.on_retry(&sending.info, attempt + 1, &cause);
match retry_after {
Some(seconds)
if status == StatusCode::TOO_MANY_REQUESTS && seconds > 0 =>
{
self.wait_as_asked(Duration::from_secs(seconds)).await;
}
_ => self.wait(delay).await,
}
delay = self.next_delay(delay);
attempt += 1;
} else {
return Ok(Answered {
url: final_url,
response,
cached: cached.take(),
sending,
duration,
retryable,
retry_after,
});
}
}
}
}
}
async fn refresh_credentials(&self, signed_under: u64) -> bool {
let shared = self.shared.clone();
let interest = Interest::new();
let wanted = interest.wanted.clone();
let refresh = tokio::spawn(async move {
let _turn = shared.refreshing.write().await;
if shared.refreshes.load(Ordering::Acquire) != signed_under {
true
} else if !wanted.load(Ordering::Acquire) {
false
} else if shared.auth.refresh().await {
shared.refreshes.fetch_add(1, Ordering::AcqRel);
true
} else {
false
}
});
let refreshed = refresh.await.unwrap_or(false);
drop(interest);
refreshed
}
pub(crate) fn url_for(&self, operation: &Operation) -> Result<Url, Error> {
let mut url = if let Some(url) = &operation.url {
url.clone()
} else {
let mut path = operation.path.clone();
if operation.json_suffix {
path = with_json_extension(&path);
}
self.shared.base_url.join(path.trim_start_matches('/'))?
};
if !operation.query.is_empty() {
url.query_pairs_mut().extend_pairs(&operation.query);
}
if let Some(account_id) = self.account_id
&& is_same_origin(&url, &self.shared.base_url)
{
let others: Vec<(String, String)> = url
.query_pairs()
.filter(|(name, _)| name != ACCOUNT_FILTER_PARAMETER)
.map(|(name, value)| (name.into_owned(), value.into_owned()))
.collect();
url.query_pairs_mut()
.clear()
.extend_pairs(others)
.append_pair(ACCOUNT_FILTER_PARAMETER, &account_id.to_string());
}
Ok(url)
}
async fn prepare(
&self,
operation: &Operation,
url: &Url,
cached: &mut Option<(String, CachedResponse)>,
) -> Result<Request<Bytes>, Error> {
let mut request = Request::builder()
.method(operation.method.clone())
.uri(url.as_str())
.body(Bytes::new())
.map_err(Error::from_std)?;
let headers = request.headers_mut();
headers.insert(USER_AGENT, header_value(&self.shared.user_agent)?);
headers.insert(ACCEPT, HeaderValue::from_static(operation.accept));
if let Some(body) = &operation.body {
headers.insert(CONTENT_TYPE, header_value(&body.content_type)?);
*request.body_mut() = body.bytes.clone();
}
self.shared.auth.authenticate(&mut request).await?;
let key = match self.cacheable(operation) {
None => None,
Some(cache) => match request
.headers()
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
{
None => None,
Some(credential) => {
let key = cache_key(url.as_str(), credential);
if cached.as_ref().is_none_or(|(held, _)| *held != key) {
*cached = self.look_up(cache, &key).await;
}
Some(key)
}
},
};
if key.is_none() {
*cached = None;
}
if let Some((_, entry)) = cached.as_ref()
&& !entry.etag.is_empty()
{
let validator = header_value(&entry.etag)?;
request.headers_mut().insert(IF_NONE_MATCH, validator);
}
Ok(request)
}
async fn look_up(
&self,
cache: &Arc<dyn ResponseCache>,
key: &str,
) -> Option<(String, CachedResponse)> {
match cache_get(cache, key).await {
Some(entry) if entry.body.len() <= self.shared.max_response_body_bytes => {
Some((key.to_string(), entry))
}
Some(_) => {
cache_invalidate(cache, key).await;
None
}
None => Some((
key.to_string(),
CachedResponse {
etag: String::new(),
body: Bytes::new(),
},
)),
}
}
fn cacheable(&self, operation: &Operation) -> Option<&Arc<dyn ResponseCache>> {
if !operation.no_cache
&& operation.method == Method::GET
&& operation.accept == "application/json"
{
self.shared.cache.as_ref()
} else {
None
}
}
async fn transmit(
&self,
operation: &Operation,
mut url: Url,
mut request: Request<Bytes>,
) -> Result<(Url, HttpResponse<Body>), Error> {
let mut hops = 0;
loop {
let outgoing = (
request.method().clone(),
request.headers().clone(),
request.body().clone(),
);
let response = self.shared.http.send(request).await?;
let next = if operation.capture_redirects {
None
} else {
redirect_target(&url, &response)
};
match next {
None => return Ok((url, response)),
Some(_) if hops == MAX_REDIRECTS => {
return Err(Error::new(
ErrorCode::Network,
format!(
"{} redirected more than {MAX_REDIRECTS} times",
operation.label()
),
)
.retryable());
}
Some(next) => {
require_secure_endpoint(&next)?;
request = redirected(outgoing, response.status(), &url, &next)?;
url = next;
hops += 1;
}
}
}
}
fn buffer_bound(&self, operation: &Operation) -> usize {
if is_parsed(operation.accept) {
self.shared.max_response_body_bytes
} else {
MAX_RESPONSE_BODY_BYTES
}
}
async fn finish(
&self,
operation: &Operation,
url: &Url,
final_url: Url,
response: HttpResponse<Body>,
cached: Option<(String, CachedResponse)>,
) -> Result<Response, Error> {
let status = response.status();
let headers = response.headers().clone();
if status == StatusCode::NOT_MODIFIED {
return match cached {
Some((_, entry)) if !entry.etag.is_empty() => Ok(Response {
status: StatusCode::OK,
headers,
body: entry.body,
url: final_url,
from_cache: true,
empty: false,
}),
_ => Err(Error::api(
304,
"304 received but no cached response available",
)),
};
}
let bound = self.buffer_bound(operation);
let body = match read_body(response.into_body(), bound, &operation.method, url.path()).await
{
Ok(body) => body,
Err(refusal) if status.is_success() => return Err(refusal),
Err(refusal) => {
return Err(
Error::from_response(status, &operation.method, &headers, &[])
.refusing(refusal),
);
}
};
if status.is_success() {
if let (Some((key, _)), Some(cache)) = (cached, self.cacheable(operation))
&& let Some(etag) = headers.get("etag").and_then(|value| value.to_str().ok())
{
cache_set(
cache,
&key,
CachedResponse {
etag: etag.to_string(),
body: body.clone(),
},
)
.await;
}
Ok(Response {
status,
headers,
body,
url: final_url,
from_cache: false,
empty: false,
})
} else if operation.empty_on.contains(&status.as_u16()) {
Ok(Response {
status,
headers,
body,
url: final_url,
from_cache: false,
empty: true,
})
} else {
Err(Error::from_response(
status,
&operation.method,
&headers,
&body,
))
}
}
async fn wait(&self, delay: Duration) {
tokio::time::sleep((delay + self.jitter()).min(self.shared.max_delay)).await;
}
async fn wait_as_asked(&self, delay: Duration) {
tokio::time::sleep(delay + self.jitter()).await;
}
fn jitter(&self) -> Duration {
match self.shared.max_jitter.as_millis() {
0 => Duration::ZERO,
millis => Duration::from_millis(rand::random_range(
0..u64::try_from(millis).unwrap_or(u64::MAX),
)),
}
}
fn next_delay(&self, delay: Duration) -> Duration {
(delay * 2).min(self.shared.max_delay)
}
}
struct Running<'a> {
hooks: &'a Arc<dyn Hooks>,
info: &'a OperationInfo,
state: Option<OperationState>,
started: Instant,
}
impl Running<'_> {
fn finished(&mut self, outcome: Result<(), &Error>) {
if let Some(state) = self.state.take() {
self.hooks
.on_operation_end(self.info, state, outcome, self.started.elapsed());
}
}
}
impl Drop for Running<'_> {
fn drop(&mut self) {
if self.state.is_some() {
self.finished(Err(&Error::cancelled()));
}
}
}
struct Budget {
attempts: u32,
retry_on: &'static [u16],
delay: Duration,
}
struct Interest {
wanted: Arc<AtomicBool>,
}
impl Interest {
fn new() -> Interest {
Interest {
wanted: Arc::new(AtomicBool::new(true)),
}
}
}
impl Drop for Interest {
fn drop(&mut self) {
self.wanted.store(false, Ordering::Release);
}
}
struct Sending {
hooks: Arc<dyn Hooks>,
info: RequestInfo,
started: Instant,
owed: bool,
}
impl Sending {
fn start(hooks: Arc<dyn Hooks>, info: RequestInfo) -> Sending {
hooks.on_request_start(&info);
Sending {
hooks,
info,
started: Instant::now(),
owed: true,
}
}
fn end(&mut self, result: &RequestResult<'_>) {
self.owed = false;
self.hooks.on_request_end(&self.info, result);
}
}
impl Drop for Sending {
fn drop(&mut self) {
if self.owed {
self.end(&RequestResult {
status: None,
duration: self.started.elapsed(),
error: Some(&Error::cancelled()),
from_cache: false,
retryable: false,
retry_after: None,
});
}
}
}
struct Answered {
url: Url,
response: HttpResponse<Body>,
cached: Option<(String, CachedResponse)>,
sending: Sending,
duration: Duration,
retryable: bool,
retry_after: Option<u64>,
}
async fn cache_get(cache: &Arc<dyn ResponseCache>, key: &str) -> Option<CachedResponse> {
let cache = cache.clone();
let key = key.to_string();
tokio::task::spawn_blocking(move || cache.get(&key))
.await
.ok()
.flatten()
}
async fn cache_set(cache: &Arc<dyn ResponseCache>, key: &str, response: CachedResponse) {
let cache = cache.clone();
let key = key.to_string();
let _ = tokio::task::spawn_blocking(move || cache.set(&key, response)).await;
}
async fn cache_invalidate(cache: &Arc<dyn ResponseCache>, key: &str) {
let cache = cache.clone();
let key = key.to_string();
let _ = tokio::task::spawn_blocking(move || cache.invalidate(&key)).await;
}
#[cfg(feature = "reqwest")]
fn shipped_http_client(timeout: Duration) -> Result<Arc<dyn HttpClient>, Error> {
Ok(Arc::new(crate::http::ReqwestClient::with_timeout(timeout)?))
}
#[cfg(not(feature = "reqwest"))]
fn shipped_http_client(_timeout: Duration) -> Result<Arc<dyn HttpClient>, Error> {
Err(Error::usage(
"no HTTP client: supply one with ClientBuilder::http_client, or enable the reqwest feature",
))
}
fn redirect_target(url: &Url, response: &HttpResponse<Body>) -> Option<Url> {
let status = response.status();
if status.is_redirection() && status != StatusCode::NOT_MODIFIED {
response
.headers()
.get("location")
.and_then(|value| value.to_str().ok())
.and_then(|location| url.join(location).ok())
} else {
None
}
}
fn redirected(
(method, mut headers, body): (Method, HeaderMap, Bytes),
status: StatusCode,
from: &Url,
next: &Url,
) -> Result<Request<Bytes>, Error> {
let keeps_method = method == Method::GET
|| method == Method::HEAD
|| status == StatusCode::TEMPORARY_REDIRECT
|| status == StatusCode::PERMANENT_REDIRECT;
let (method, body) = if keeps_method {
(method, body)
} else {
headers.remove(CONTENT_TYPE);
headers.remove(CONTENT_LENGTH);
(Method::GET, Bytes::new())
};
if !is_same_origin(next, from) {
headers.remove(AUTHORIZATION);
headers.remove(COOKIE);
headers.remove(PROXY_AUTHORIZATION);
}
let mut request = Request::builder()
.method(method)
.uri(next.as_str())
.body(body)
.map_err(Error::from_std)?;
*request.headers_mut() = headers;
Ok(request)
}
fn parse_base_url(base_url: &str) -> Result<Url, Error> {
let mut url = Url::parse(base_url)
.map_err(|error| Error::usage(format!("base URL {base_url}: {error}")))?;
require_secure_endpoint(&url)?;
if !url.path().ends_with('/') {
url.set_path(&format!("{}/", url.path()));
}
Ok(url)
}
pub(crate) fn with_json_extension(path: &str) -> String {
let last_segment = path.rsplit('/').next().unwrap_or_default();
if path.is_empty() || path.ends_with('/') || last_segment.contains('.') {
path.to_string()
} else {
format!("{path}.json")
}
}
fn span_for(operation: &Operation) -> OperationSpan {
if operation.quiet {
OperationSpan::none()
} else {
OperationSpan::new(operation)
}
}
fn request_id(headers: &HeaderMap) -> Option<&str> {
headers
.get("x-request-id")
.and_then(|value| value.to_str().ok())
}
fn retry_after_asked(status: StatusCode, headers: &HeaderMap) -> Option<u64> {
if status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::SERVICE_UNAVAILABLE {
retry_after_seconds(headers)
} else {
None
}
}
fn header_value(value: &str) -> Result<HeaderValue, Error> {
HeaderValue::from_str(value)
.map_err(|_| Error::usage(format!("{value:?} is not a valid header value")))
}
fn is_parsed(accept: &str) -> bool {
accept.is_empty()
|| accept.split(',').any(|part| {
let media_type = part.split(';').next().unwrap_or_default().trim();
media_type == "application/json"
|| media_type.ends_with("+json")
|| media_type == "text/html"
})
}
pub(crate) async fn read_body(
body: Body,
limit: usize,
method: &Method,
path: &str,
) -> Result<Bytes, Error> {
body.collect(limit, || Error::response_too_large(limit, method, path))
.await
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use async_trait::async_trait;
use serde_json::Value;
use super::*;
use crate::auth::StaticTokenProvider;
struct Canned {
answer: Box<Answer>,
sent: Mutex<Vec<(Method, String, HeaderMap)>>,
}
type Answer = dyn Fn(&Request<Bytes>) -> HttpResponse<Body> + Send + Sync;
impl Canned {
fn new(
answer: impl Fn(&Request<Bytes>) -> HttpResponse<Body> + Send + Sync + 'static,
) -> Arc<Canned> {
Arc::new(Canned {
answer: Box::new(answer),
sent: Mutex::new(Vec::new()),
})
}
fn sent(&self) -> Vec<(Method, String, HeaderMap)> {
self.sent.lock().unwrap().clone()
}
}
#[async_trait]
impl HttpClient for Arc<Canned> {
async fn send(&self, request: Request<Bytes>) -> Result<HttpResponse<Body>, Error> {
self.sent.lock().unwrap().push((
request.method().clone(),
request.uri().to_string(),
request.headers().clone(),
));
Ok((self.answer)(&request))
}
}
fn answer(status: u16, body: &'static str) -> HttpResponse<Body> {
let mut response = HttpResponse::new(Body::from(body));
*response.status_mut() = StatusCode::from_u16(status).unwrap();
response
}
fn redirect(location: &str) -> HttpResponse<Body> {
let mut response = answer(302, "");
response
.headers_mut()
.insert("location", HeaderValue::from_str(location).unwrap());
response
}
fn client_over(http: Arc<Canned>) -> Client {
Client::builder(Config::default().with_base_url("https://hey.test"))
.token_provider(StaticTokenProvider::new("secret"))
.http_client(http)
.max_retries(0)
.build()
.unwrap()
}
#[tokio::test]
async fn a_request_goes_out_on_the_supplied_http_client_with_credentials() {
let http = Canned::new(|_| answer(200, r#"{"ok":true}"#));
let client = client_over(http.clone());
let body: Value = client
.send(client.request(Method::GET, "/boxes"))
.await
.unwrap();
assert_eq!(body, serde_json::json!({ "ok": true }));
let sent = http.sent();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].0, Method::GET);
assert_eq!(sent[0].1, "https://hey.test/boxes.json");
assert_eq!(sent[0].2[AUTHORIZATION], "Bearer secret");
}
#[tokio::test]
async fn a_redirect_on_the_same_origin_is_followed_with_credentials() {
let http = Canned::new(|request| {
if request.uri().path() == "/old.json" {
redirect("/new.json")
} else {
answer(200, r#"{"moved":true}"#)
}
});
let client = client_over(http.clone());
let response = client
.execute(client.request(Method::GET, "/old"))
.await
.unwrap();
assert_eq!(response.url.as_str(), "https://hey.test/new.json");
assert_eq!(response.body, r#"{"moved":true}"#);
let sent = http.sent();
assert_eq!(sent.len(), 2);
assert_eq!(sent[1].1, "https://hey.test/new.json");
assert_eq!(sent[1].2[AUTHORIZATION], "Bearer secret");
}
#[tokio::test]
async fn an_html_read_asks_for_the_page_as_hey_serves_it() {
let http = Canned::new(|_| {
answer(
200,
r#"<section id="container_workflow_stage_5512"></section>"#,
)
});
let client = client_over(http.clone());
let page = client.workflows().get_stage(8801, 5512).await.unwrap();
assert_eq!(
page,
r#"<section id="container_workflow_stage_5512"></section>"#
);
let sent = http.sent();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].1, "https://hey.test/workflows/8801/stages/5512");
assert_eq!(sent[0].2[ACCEPT], "text/html");
assert_eq!(sent[0].2[AUTHORIZATION], "Bearer secret");
}
#[tokio::test]
async fn a_redirect_off_the_origin_is_followed_without_credentials() {
let http = Canned::new(|request| {
if request.uri().host() == Some("hey.test") {
redirect("https://storage.test/blobs/1")
} else {
answer(200, "the bytes")
}
});
let client = client_over(http.clone());
let response = client.get_blob("/blobs/1").await.unwrap();
assert_eq!(response.body, "the bytes");
let sent = http.sent();
assert_eq!(sent.len(), 2);
assert_eq!(sent[1].1, "https://storage.test/blobs/1");
assert!(sent[1].2.get(AUTHORIZATION).is_none());
}
#[tokio::test]
async fn a_redirect_to_plain_http_elsewhere_is_refused() {
let http = Canned::new(|_| redirect("http://evil.test/"));
let client = client_over(http.clone());
let error = client.get("/anything").await.unwrap_err();
assert_eq!(error.code(), ErrorCode::Usage);
assert_eq!(http.sent().len(), 1);
}
#[tokio::test]
async fn a_redirect_loop_is_given_up_on() {
let http = Canned::new(|_| redirect("/again"));
let client = client_over(http.clone());
let error = client.get("/again").await.unwrap_err();
assert_eq!(error.code(), ErrorCode::Network);
assert_eq!(http.sent().len(), MAX_REDIRECTS + 1);
}
#[tokio::test]
async fn a_form_request_keeps_its_redirect_rather_than_following_it() {
let http = Canned::new(|_| redirect("/workflows/8801"));
let client = client_over(http.clone());
let created = client
.post_form("/workflows", &[("workflow[name]", "Launch")])
.await
.unwrap();
assert_eq!(created.location.as_deref(), Some("/workflows/8801"));
assert_eq!(http.sent().len(), 1);
}
#[test]
fn json_extension_is_added_only_where_missing() {
assert_eq!(with_json_extension("/boxes/123"), "/boxes/123.json");
assert_eq!(with_json_extension("/boxes.json"), "/boxes.json");
assert_eq!(
with_json_extension("/calendar/days/2026-03-04/journal_entry"),
"/calendar/days/2026-03-04/journal_entry.json"
);
assert_eq!(
with_json_extension("/rails/active_storage/direct_uploads.json"),
"/rails/active_storage/direct_uploads.json"
);
assert_eq!(with_json_extension("/boxes/"), "/boxes/");
}
#[test]
fn base_url_must_be_https_or_local() {
assert!(parse_base_url("https://app.hey.com").is_ok());
assert!(parse_base_url("http://127.0.0.1:3000").is_ok());
assert_eq!(
parse_base_url("http://evil.example.com")
.unwrap_err()
.code(),
crate::ErrorCode::Usage
);
}
}