use std::time::{Duration, Instant};
use reqwest::header::CONTENT_TYPE;
use reqwest::Method;
use serde::de::DeserializeOwned;
use super::core::Client;
use super::options::{
RequestOptions, CLOUDFLARE_RUN_PATH, HEALTH_PATH, MODELS_PATH, RETRY_COUNT_HEADER,
SYSTEM_ONE_PATH,
};
use crate::error::{parse_retry_after, ApiError, Error, REQUEST_ID_HEADER};
pub const MAX_RESPONSE_BODY_SIZE: usize = 8 * 1024 * 1024;
pub(crate) struct RequestDesc<'a> {
pub(crate) method: Method,
pub(crate) url: &'a reqwest::Url,
pub(crate) endpoint: &'static str,
pub(crate) body: Option<Vec<u8>>,
pub(crate) timeout: Duration,
}
impl Client {
pub(crate) async fn send_json<T: DeserializeOwned>(
&self,
method: Method,
url: &reqwest::Url,
path: &'static str,
body: Option<Vec<u8>>,
opts: &RequestOptions,
) -> Result<T, Error> {
let policy = opts.retry.as_ref().unwrap_or(&self.inner.retry);
policy.validate()?;
let endpoint: &'static str = match path {
SYSTEM_ONE_PATH => "POST /v1/systemone",
CLOUDFLARE_RUN_PATH => "POST /ai/run",
MODELS_PATH => "GET /v1/models",
HEALTH_PATH => "GET /health",
_ => "GET /",
};
let desc = RequestDesc {
method,
url,
endpoint,
body,
timeout: opts.timeout.unwrap_or(self.inner.timeout),
};
if desc.timeout.is_zero() {
return Err(Error::Config("timeout must be greater than zero".into()));
}
let start = Instant::now();
let mut retries: u32 = 0;
loop {
match self.attempt_once::<T>(&desc, opts, retries).await {
Ok(response) => return Ok(response),
Err(error) => {
if retries >= policy.max_retries || !policy.is_retryable(&error) {
return Err(error);
}
let delay = policy.delay_for(&error, retries);
if let Some(budget) = policy.total_timeout {
if delay >= budget.saturating_sub(start.elapsed()) {
return Err(error);
}
}
retries += 1;
tracing::debug!(
endpoint = %desc.endpoint,
retry = retries,
?delay,
error = %error,
"retrying request"
);
tokio::time::sleep(delay).await;
}
}
}
}
async fn attempt_once<T: DeserializeOwned>(
&self,
desc: &RequestDesc<'_>,
opts: &RequestOptions,
retries: u32,
) -> Result<T, Error> {
let mut headers = self.inner.base_headers.clone();
if desc.body.is_some() {
headers.remove(CONTENT_TYPE);
}
if let Some(extra) = &opts.extra_headers {
for (name, value) in extra {
if is_user_overridable(name.as_str(), desc.body.is_some()) {
headers.insert(name.clone(), value.clone());
}
}
}
if let Some(pool) = self.inner.fast_h1.as_ref() {
let target = request_target(desc.url);
let wire = super::http1::encode_request(
desc.method.as_str(),
&target,
pool.authority(),
&headers,
desc.body.as_deref(),
(retries > 0).then_some(retries),
);
match super::http1::exchange(pool, &wire, MAX_RESPONSE_BODY_SIZE, desc.timeout).await {
Ok(Ok(response)) => {
let retry_after = if (200..300).contains(&response.status) {
None
} else {
crate::error::parse_retry_after_with(|name| response.header(name))
};
return finish_response(
response.status,
response.header(REQUEST_ID_HEADER).map(str::to_owned),
retry_after,
response.body,
desc,
);
}
Ok(Err(super::http1::FastError::Redirect)) => {}
Ok(Err(error)) => {
return Err(super::http1::classify_fast_error(
error,
desc.timeout,
MAX_RESPONSE_BODY_SIZE,
))
}
Err(timeout_error) => return Err(timeout_error),
}
}
let mut request = self
.inner
.http
.request(desc.method.clone(), desc.url.clone())
.headers(headers)
.timeout(desc.timeout);
if retries > 0 {
request = request.header(RETRY_COUNT_HEADER, retries.to_string());
}
if let Some(body) = &desc.body {
request = request
.header(CONTENT_TYPE, "application/json")
.body(body.clone());
}
let mut response = request
.send()
.await
.map_err(|e| classify_transport_error(e, desc.timeout))?;
let status = response.status();
let request_id = response
.headers()
.get(REQUEST_ID_HEADER)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let retry_after = if status.is_success() {
None
} else {
parse_retry_after(response.headers())
};
if response
.content_length()
.is_some_and(|length| length > MAX_RESPONSE_BODY_SIZE as u64)
{
return Err(Error::ResponseTooLarge {
limit: MAX_RESPONSE_BODY_SIZE,
});
}
let mut bytes = Vec::with_capacity(
response
.content_length()
.map(|length| (length as usize).min(MAX_RESPONSE_BODY_SIZE))
.unwrap_or(0),
);
while let Some(chunk) = response
.chunk()
.await
.map_err(|e| classify_transport_error(e, desc.timeout))?
{
if chunk.len() > MAX_RESPONSE_BODY_SIZE - bytes.len() {
return Err(Error::ResponseTooLarge {
limit: MAX_RESPONSE_BODY_SIZE,
});
}
bytes.extend_from_slice(&chunk);
}
if !status.is_success() {
return Err(Error::Api(Box::new(ApiError::from_response(
status.as_u16(),
request_id,
retry_after,
&bytes,
desc.endpoint.to_string(),
))));
}
finish_response(status.as_u16(), request_id, retry_after, bytes, desc)
}
}
fn finish_response<T: DeserializeOwned>(
status: u16,
request_id: Option<String>,
retry_after: Option<std::time::Duration>,
bytes: Vec<u8>,
desc: &RequestDesc<'_>,
) -> Result<T, Error> {
if !(200..300).contains(&status) {
return Err(Error::Api(Box::new(ApiError::from_response(
status,
request_id,
retry_after,
&bytes,
desc.endpoint.to_string(),
))));
}
serde_json::from_slice(&bytes).map_err(|source| Error::Decode {
status,
body_excerpt: String::from_utf8_lossy(&bytes).chars().take(200).collect(),
source,
})
}
fn request_target(url: &reqwest::Url) -> String {
match url.query() {
Some(query) => format!("{}?{}", url.path(), query),
None => url.path().to_owned(),
}
}
pub(crate) fn is_user_overridable(name: &str, has_body: bool) -> bool {
match name {
"authorization"
| "accept"
| "user-agent"
| "x-typesafe-sdk"
| "x-typesafe-runtime"
| "x-typesafe-retry-count"
| "host"
| "content-length"
| "transfer-encoding" => false,
"content-type" => !has_body,
_ => true,
}
}
pub(crate) fn classify_transport_error(error: reqwest::Error, timeout: Duration) -> Error {
if error.is_timeout() {
Error::Timeout { timeout }
} else {
Error::Connection(Box::new(error))
}
}