Skip to main content

fizzy_sdk/
operation.rs

1//! A request that has not been sent yet, and the retry policy it carries.
2
3use std::borrow::Cow;
4use std::fmt::Display;
5use std::time::Duration;
6
7use bytes::Bytes;
8use serde::Serialize;
9use url::Url;
10
11use crate::error::Error;
12use crate::http::{HeaderName, HeaderValue, Method};
13use crate::observability::OperationInfo;
14use crate::route::Route;
15
16/// The statuses a call is resent on when nothing more specific says: what the behavior
17/// model gives every retried operation.
18pub const DEFAULT_RETRY_ON: &[u16] = &[429, 500, 503];
19
20/// How many times an operation may be sent, and how the waits between attempts grow. Every
21/// operation carries one: a modelled route's comes from the behavior model, a raw call's
22/// from the client, and both are held under the client's own ceilings.
23#[derive(Debug, Clone, PartialEq, Eq)]
24#[non_exhaustive]
25pub struct RetryPolicy {
26    /// Total attempts, the first included. One means the call is never resent.
27    pub attempts: u32,
28    /// The first backoff, doubled after every attempt.
29    pub base_delay: Duration,
30    /// The statuses that are resent.
31    pub retry_on: Cow<'static, [u16]>,
32}
33
34impl RetryPolicy {
35    /// A call sent once and never resent.
36    pub fn none() -> RetryPolicy {
37        RetryPolicy {
38            attempts: 1,
39            base_delay: Duration::ZERO,
40            retry_on: Cow::Borrowed(&[]),
41        }
42    }
43
44    /// Whether an answer with this status is resent.
45    pub fn retries(&self, status: u16) -> bool {
46        self.attempts > 1 && self.retry_on.contains(&status)
47    }
48}
49
50/// A request the client has not sent yet. Generated service methods build one from a
51/// [`Route`]; [`crate::Client::request`] builds one for anything the model does not cover.
52#[derive(Clone)]
53pub struct Operation {
54    pub(crate) id: Cow<'static, str>,
55    pub(crate) info: OperationInfo,
56    pub(crate) method: Method,
57    pub(crate) path: String,
58    pub(crate) url: Option<Url>,
59    pub(crate) query: Vec<(String, String)>,
60    pub(crate) headers: Vec<(HeaderName, HeaderValue)>,
61    pub(crate) body: Option<Body>,
62    pub(crate) idempotent: bool,
63    /// `None` until the client sends it, when the client's defaults and ceilings are
64    /// applied; `Some` once the route or the caller has said.
65    pub(crate) retry: Option<RetryPolicy>,
66    pub(crate) no_cache: bool,
67}
68
69#[derive(Clone)]
70pub(crate) struct Body {
71    pub(crate) content_type: String,
72    pub(crate) bytes: Bytes,
73}
74
75/// The body never prints: a request body may carry an email address or a token, and
76/// `{:?}` of an operation is the kind of thing that ends up in a log.
77impl std::fmt::Debug for Body {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        f.debug_struct("Body")
80            .field("content_type", &self.content_type)
81            .field("len", &self.bytes.len())
82            .finish()
83    }
84}
85
86impl std::fmt::Debug for Operation {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.debug_struct("Operation")
89            .field("id", &self.id)
90            .field("method", &self.method)
91            .field("path", &self.path)
92            .field("query", &self.query)
93            .field("body", &self.body)
94            .field("idempotent", &self.idempotent)
95            .field("retry", &self.retry)
96            .finish_non_exhaustive()
97    }
98}
99
100impl Operation {
101    pub(crate) fn for_route(
102        route: &'static Route,
103        account_id: Option<&str>,
104        params: &[&dyn Display],
105    ) -> Result<Operation, Error> {
106        let path = route.try_fill(account_id, params)?;
107        let retry = route
108            .retry
109            .map_or_else(RetryPolicy::none, |retry| RetryPolicy {
110                attempts: retry.max,
111                base_delay: Duration::from_millis(retry.base_delay_ms),
112                retry_on: Cow::Borrowed(retry.retry_on),
113            });
114        Ok(Operation {
115            id: Cow::Borrowed(route.id),
116            info: OperationInfo {
117                service: Cow::Borrowed(route.service),
118                operation: Cow::Borrowed(route.id),
119                resource_type: Cow::Borrowed(route.resource_type),
120                is_mutation: !route.readonly,
121                resource_id: None,
122            },
123            method: route.method.clone(),
124            path,
125            url: None,
126            query: Vec::new(),
127            headers: Vec::new(),
128            body: None,
129            idempotent: route.idempotent,
130            retry: Some(retry),
131            no_cache: false,
132        })
133    }
134
135    /// A call for a path the model does not cover. Everything but a POST is taken as safe
136    /// to resend, which is how Fizzy's other SDKs treat their raw verbs; a POST is sent
137    /// once unless [`Operation::idempotent`] says otherwise.
138    pub(crate) fn raw(method: Method, path: String) -> Operation {
139        let idempotent = method != Method::POST;
140        let id = format!("{method} {path}");
141        // Scoped by verb, not path: the breaker and bulkhead keep one entry per operation
142        // for the client's life, and raw paths are as many as the caller's resources.
143        let info = OperationInfo {
144            service: Cow::Borrowed("Raw"),
145            operation: Cow::Owned(method.to_string()),
146            resource_type: Cow::Borrowed("raw"),
147            is_mutation: method != Method::GET,
148            resource_id: None,
149        };
150        Operation {
151            id: Cow::Owned(id),
152            info,
153            method,
154            path,
155            url: None,
156            query: Vec::new(),
157            headers: Vec::new(),
158            body: None,
159            idempotent,
160            retry: None,
161            no_cache: false,
162        }
163    }
164
165    pub(crate) fn at(method: Method, url: Url) -> Operation {
166        let mut operation = Operation::raw(method, url.path().to_string());
167        operation.url = Some(url);
168        operation
169    }
170
171    /// The operation id, or `METHOD /path` for a raw call.
172    pub fn id(&self) -> &str {
173        &self.id
174    }
175
176    /// The HTTP method.
177    pub fn method(&self) -> &Method {
178        &self.method
179    }
180
181    /// The path, account and parameters filled in.
182    pub fn path(&self) -> &str {
183        &self.path
184    }
185
186    /// The retry policy the operation will be sent under, once one has been settled.
187    pub fn retry_policy(&self) -> Option<&RetryPolicy> {
188        self.retry.as_ref()
189    }
190
191    /// Adds a query parameter.
192    pub fn query(&mut self, name: &str, value: impl Display) -> &mut Operation {
193        self.query.push((name.to_string(), value.to_string()));
194        self
195    }
196
197    /// Adds a query parameter when there is a value for it.
198    pub fn query_optional<T: Display>(&mut self, name: &str, value: Option<&T>) -> &mut Operation {
199        if let Some(value) = value {
200            self.query(name, value);
201        }
202        self
203    }
204
205    /// Adds a query parameter once per value, the way Rails reads `ids[]=1&ids[]=2`.
206    pub fn query_all<T: Display>(&mut self, name: &str, values: &[T]) -> &mut Operation {
207        for value in values {
208            self.query(name, value);
209        }
210        self
211    }
212
213    /// Adds a repeated query parameter when there are values for it.
214    pub fn query_all_optional<T: Display>(
215        &mut self,
216        name: &str,
217        values: Option<&[T]>,
218    ) -> &mut Operation {
219        if let Some(values) = values {
220            self.query_all(name, values);
221        }
222        self
223    }
224
225    /// Adds a header of the caller's own. Credentials go on through the client's
226    /// [`crate::AuthStrategy`], not here.
227    pub fn header(&mut self, name: HeaderName, value: HeaderValue) -> &mut Operation {
228        self.headers.push((name, value));
229        self
230    }
231
232    /// Sends a JSON body.
233    pub fn json<T: Serialize + ?Sized>(&mut self, body: &T) -> Result<&mut Operation, Error> {
234        self.body_bytes("application/json", Bytes::from(serde_json::to_vec(body)?));
235        Ok(self)
236    }
237
238    /// A body the caller encoded, for the representations the model does not describe.
239    pub fn body_bytes(&mut self, content_type: impl Into<String>, bytes: Bytes) -> &mut Operation {
240        self.body = Some(Body {
241            content_type: content_type.into(),
242            bytes,
243        });
244        self
245    }
246
247    /// Replaces the whole of what the operation announces itself as to the client's
248    /// [`crate::observability::Hooks`].
249    pub fn info(&mut self, info: OperationInfo) -> &mut Operation {
250        self.info = info;
251        self
252    }
253
254    /// Announces the operation as something other than the route it sends.
255    pub fn operation_name(&mut self, name: impl Into<Cow<'static, str>>) -> &mut Operation {
256        self.info.operation = name.into();
257        self
258    }
259
260    /// Names the kind of record the operation acts on.
261    pub fn resource_type(&mut self, resource_type: impl Into<Cow<'static, str>>) -> &mut Operation {
262        self.info.resource_type = resource_type.into();
263        self
264    }
265
266    /// Names the record the operation acts on. Generated methods set this from the path
267    /// parameter that names it.
268    pub fn resource_id(&mut self, resource_id: impl Display) -> &mut Operation {
269        self.info.resource_id = Some(resource_id.to_string());
270        self
271    }
272
273    /// Marks the operation as safe to resend, or not, regardless of its HTTP method. A POST
274    /// marked idempotent is retried like a GET.
275    pub fn idempotent(&mut self, idempotent: bool) -> &mut Operation {
276        self.idempotent = idempotent;
277        self
278    }
279
280    /// Sends the operation once, whatever the route or the client would have allowed.
281    pub fn no_retry(&mut self) -> &mut Operation {
282        self.retry = Some(RetryPolicy::none());
283        self
284    }
285
286    /// Sends the operation under a policy of the caller's own. The client's ceilings still
287    /// apply.
288    pub fn retry(&mut self, policy: RetryPolicy) -> &mut Operation {
289        self.retry = Some(policy);
290        self
291    }
292
293    /// Reads past the response cache for this send, so the answer is Fizzy's own.
294    pub fn no_cache(&mut self) -> &mut Operation {
295        self.no_cache = true;
296        self
297    }
298}
299
300#[cfg(test)]
301mod raw_scope {
302    use super::*;
303
304    #[test]
305    fn raw_calls_share_one_scope_per_verb() {
306        let one = Operation::raw(Method::GET, "/999/cards/1".to_string());
307        let two = Operation::raw(Method::GET, "/999/cards/2".to_string());
308        assert_eq!(one.info.operation, two.info.operation);
309        assert_eq!(one.info.operation, "GET");
310        assert_eq!(one.id, "GET /999/cards/1");
311    }
312}