Skip to main content

kunobi_decision/client/
call.rs

1//! A pending API call with per-call options.
2
3use std::future::{Future, IntoFuture};
4use std::pin::Pin;
5use std::time::Duration;
6
7use bytes::Bytes;
8use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
9use reqwest::{Method, StatusCode};
10use serde::de::DeserializeOwned;
11
12use super::Client;
13use super::builder::validate_timeout;
14use super::transport::Resolved;
15use crate::error::{Error, Result};
16use crate::retry::RetryPolicy;
17
18/// Why a 2xx body could not be turned into the expected value.
19pub(crate) struct DecodeFailure {
20    pub(crate) message: String,
21    pub(crate) source: Option<serde_json::Error>,
22}
23
24pub(crate) type Parser<T> = fn(&[u8]) -> std::result::Result<T, DecodeFailure>;
25
26pub(crate) fn parse_json<T: DeserializeOwned>(
27    body: &[u8],
28) -> std::result::Result<T, DecodeFailure> {
29    serde_json::from_slice(body).map_err(|err| DecodeFailure {
30        message: format!("Could not parse the response body: {err}"),
31        source: Some(err),
32    })
33}
34
35/// A pending call. Await it for the parsed result, or set per-call options first.
36///
37/// Nothing is sent until the call is awaited. Dropping the future cancels the
38/// request and any pending retry.
39///
40/// ```no_run
41/// # async fn example(client: kunobi_decision::Client) -> kunobi_decision::Result<()> {
42/// use std::time::Duration;
43///
44/// let models = client
45///     .models()
46///     .list()
47///     .timeout(Duration::from_secs(2))
48///     .max_retries(0)
49///     .with_response()
50///     .await?;
51/// println!("{:?} {:?}", models.request_id, models.data);
52/// # Ok(()) }
53/// ```
54#[must_use = "a call does nothing until it is awaited"]
55pub struct Call<T> {
56    client: Client,
57    method: Method,
58    path: String,
59    body: Result<Option<Bytes>>,
60    timeout: Option<Duration>,
61    /// Outer `None` means "not set, use the client's"; inner `None` means this
62    /// call removed the bound.
63    total_timeout: Option<Option<Duration>>,
64    retry: Option<RetryPolicy>,
65    max_retries: Option<u32>,
66    headers: HeaderMap,
67    parse: Parser<T>,
68}
69
70/// Parsed data with its HTTP status, headers and request ID.
71#[derive(Debug, Clone)]
72pub struct WithResponse<T> {
73    /// The parsed response body.
74    pub data: T,
75    /// The HTTP status.
76    pub status: StatusCode,
77    /// The response headers.
78    pub headers: HeaderMap,
79    /// Request ID from `x-typesafe-request-id`.
80    pub request_id: Option<String>,
81}
82
83/// A successful response with its body unparsed.
84#[derive(Debug, Clone)]
85pub struct RawResponse {
86    /// The HTTP status, always 2xx.
87    pub status: StatusCode,
88    /// The response headers.
89    pub headers: HeaderMap,
90    /// The full response body.
91    pub body: Bytes,
92    /// Request ID from `x-typesafe-request-id`.
93    pub request_id: Option<String>,
94}
95
96impl<T> Call<T> {
97    pub(crate) fn new(
98        client: Client,
99        method: Method,
100        path: impl Into<String>,
101        body: Result<Option<Vec<u8>>>,
102        parse: Parser<T>,
103    ) -> Self {
104        Self {
105            client,
106            method,
107            path: path.into(),
108            body: body.map(|body| body.map(Bytes::from)),
109            timeout: None,
110            total_timeout: None,
111            retry: None,
112            max_retries: None,
113            headers: HeaderMap::new(),
114            parse,
115        }
116    }
117
118    /// Timeout per attempt for this call.
119    pub fn timeout(mut self, timeout: Duration) -> Self {
120        self.timeout = Some(timeout);
121        self
122    }
123
124    /// Upper bound for the whole call, including credentials, retries and backoff.
125    /// Pass `None` to remove the client's bound for this call.
126    ///
127    /// Attempts are shortened to fit the time left. A retry is skipped when its backoff
128    /// would end past the bound, and a retry cut short by the bound returns the
129    /// previous attempt's error, such as the 503 that caused the retry.
130    pub fn total_timeout(mut self, total_timeout: impl Into<Option<Duration>>) -> Self {
131        self.total_timeout = Some(total_timeout.into());
132        self
133    }
134
135    /// Retry policy for this call, replacing the client's.
136    pub fn retry(mut self, retry: RetryPolicy) -> Self {
137        self.retry = Some(retry);
138        self
139    }
140
141    /// Retries after the initial attempt for this call; `0` disables retries.
142    ///
143    /// Applies on top of the client's policy or the one set with [`Call::retry`].
144    pub fn max_retries(mut self, max_retries: u32) -> Self {
145        self.max_retries = Some(max_retries);
146        self
147    }
148
149    /// Add a header for this call, replacing a default header with the same name.
150    ///
151    /// Authentication, content negotiation and SDK headers cannot be overridden.
152    pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
153        self.headers.insert(name, value);
154        self
155    }
156
157    /// Add headers for this call, replacing earlier values for the same names.
158    pub fn headers(mut self, headers: HeaderMap) -> Self {
159        self.headers.extend(headers);
160        self
161    }
162
163    /// Send the call and return the body without parsing it.
164    pub async fn raw(self) -> Result<RawResponse> {
165        let client = self.client.clone();
166        let resolved = self.resolve()?;
167        client.execute(resolved).await
168    }
169
170    /// Send the call and return the parsed result with response metadata.
171    pub async fn with_response(self) -> Result<WithResponse<T>> {
172        let parse = self.parse;
173        let raw = self.raw().await?;
174        match parse(&raw.body) {
175            Ok(data) => Ok(WithResponse {
176                data,
177                status: raw.status,
178                headers: raw.headers,
179                request_id: raw.request_id,
180            }),
181            Err(failure) => Err(Error::Decode {
182                message: failure.message,
183                request_id: raw.request_id,
184                source: failure.source,
185            }),
186        }
187    }
188
189    fn resolve(self) -> Result<Resolved> {
190        let body = self.body?;
191        let timeout = validate_timeout(self.timeout.unwrap_or(self.client.timeout()))?;
192        let total_timeout = match self.total_timeout.unwrap_or(self.client.total_timeout()) {
193            Some(total) => Some(validate_timeout(total).map_err(|_| {
194                Error::Config("`total_timeout` must be a positive duration, got 0.".into())
195            })?),
196            None => None,
197        };
198        let mut retry = self.retry.unwrap_or_else(|| self.client.retry().clone());
199        if let Some(max_retries) = self.max_retries {
200            retry.max_retries = max_retries;
201        }
202        retry.validate()?;
203
204        let mut headers = self.client.default_headers().clone();
205        headers.extend(self.headers);
206        Ok(Resolved {
207            method: self.method,
208            path: self.path,
209            body,
210            headers,
211            timeout,
212            retry,
213            total_timeout,
214        })
215    }
216}
217
218impl<T: Send + 'static> IntoFuture for Call<T> {
219    type Output = Result<T>;
220    type IntoFuture = Pin<Box<dyn Future<Output = Result<T>> + Send>>;
221
222    fn into_future(self) -> Self::IntoFuture {
223        Box::pin(async move { self.with_response().await.map(|response| response.data) })
224    }
225}