Skip to main content

cratestack_client_rust/
idempotency.rs

1//! Per-request idempotency, carried to HTTP middleware as a typed
2//! request extension (cratestack#926).
3
4use reqwest::Method;
5
6use crate::client::CratestackClient;
7
8/// Whether one outbound request may be replayed byte-for-byte without
9/// an idempotency key — the single fact a retry middleware needs and
10/// cannot work out for itself.
11///
12/// Attached to every request this crate sends as a
13/// `reqwest_middleware` request extension, so a middleware reads it
14/// with `extensions.get::<RequestIdempotency>()`. Only observable under
15/// the `middleware` feature: reqwest 0.13 keeps
16/// `reqwest::Request::extensions_mut()` `pub(crate)`, so a bare
17/// `reqwest::Client` has no extension map a caller can write to (see
18/// `client::http::HttpRequestBuilder::with_extension`).
19///
20/// **Absent means "assume not idempotent."** A middleware that finds no
21/// `RequestIdempotency` in the extensions is talking to something other
22/// than this client, or to a build where the extension could not be
23/// attached; failing closed there is the same rule the rest of this
24/// crate follows.
25///
26/// # How the value is chosen
27///
28/// By default it is derived from the HTTP method
29/// ([`RequestIdempotency::for_method`], RFC 9110 §9.2.2), which is
30/// exactly right for REST CRUD: `GET`/`DELETE` are idempotent,
31/// `POST`/`PATCH` are not. It is *not* right for two cases the method
32/// alone cannot express, and for those the caller overrides it with
33/// [`CratestackClient::with_idempotency`]:
34///
35/// * **RPC.** Every RPC call is `POST /rpc/{op_id}`, so the method
36///   carries no information at all. The op's real answer is the
37///   `idempotent_by_default` field on the `OpDescriptor` the schema
38///   macro emits for that op (`cratestack_core::OpDescriptor`) — `true`
39///   for reads and `@query` procedures, `false` for mutations.
40/// * **REST procedures.** `POST /$procs/<name>` is a `POST` whatever
41///   the procedure does, so a `@query` procedure looks non-idempotent
42///   to the method-derived default.
43///
44/// Note that the REST-transport descriptor
45/// (`cratestack_core::RouteTransportDescriptor`) does **not** carry an
46/// `idempotent_by_default` field today — only the RPC-transport
47/// `OpDescriptor` does. Wiring either descriptor through the *generated*
48/// client so callers don't have to pass it by hand is follow-up work;
49/// this type and [`CratestackClient::with_idempotency`] are the runtime
50/// half that makes it expressible at all.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub struct RequestIdempotency {
53    idempotent: bool,
54}
55
56impl RequestIdempotency {
57    /// The request may be retried as-is.
58    pub const IDEMPOTENT: Self = Self { idempotent: true };
59
60    /// The request must not be retried without an idempotency key.
61    pub const NOT_IDEMPOTENT: Self = Self { idempotent: false };
62
63    /// Wraps a raw flag — e.g. an `OpDescriptor`'s
64    /// `idempotent_by_default`.
65    pub const fn new(idempotent: bool) -> Self {
66        Self { idempotent }
67    }
68
69    pub const fn is_idempotent(self) -> bool {
70        self.idempotent
71    }
72
73    /// The RFC 9110 §9.2.2 answer for a method: `GET`, `HEAD`, `PUT`,
74    /// `DELETE`, `OPTIONS` and `TRACE` are idempotent; everything else
75    /// — `POST` and `PATCH` included — is not.
76    ///
77    /// `PATCH` is deliberately in the second group: RFC 5789 §2 makes
78    /// it explicitly non-idempotent, and cratestack's generated `PATCH`
79    /// route is a partial update, which a retry after an unseen success
80    /// can re-apply against a changed row.
81    pub fn for_method(method: &Method) -> Self {
82        Self::new(matches!(
83            method.as_str(),
84            "GET" | "HEAD" | "PUT" | "DELETE" | "OPTIONS" | "TRACE"
85        ))
86    }
87}
88
89impl<C> CratestackClient<C> {
90    /// Pin every request this client sends to `idempotency`, overriding
91    /// the method-derived default.
92    ///
93    /// `CratestackClient` is cheap to clone (the `reqwest` client, the
94    /// state store and the authorizer are all reference-counted), so
95    /// the intended shape is a per-call clone rather than a second
96    /// long-lived client:
97    ///
98    /// ```
99    /// use cratestack_client_rust::{
100    ///     CborCodec, ClientConfig, CratestackClient, RequestIdempotency,
101    /// };
102    ///
103    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
104    /// let client = CratestackClient::cbor(ClientConfig::new(
105    ///     "https://api.example.com".parse()?,
106    /// ));
107    ///
108    /// // A `@query` procedure is a POST on the wire but is safe to
109    /// // replay — say so, and a retry middleware will retry it.
110    /// let readonly = client.clone().with_idempotency(RequestIdempotency::IDEMPOTENT);
111    /// # let _ = readonly;
112    /// # Ok(())
113    /// # }
114    /// ```
115    ///
116    /// This is the REST half of the per-call override; the RPC half is
117    /// [`RpcClient::with_idempotency`](crate::RpcClient::with_idempotency),
118    /// which forwards to this method.
119    pub fn with_idempotency(mut self, idempotency: RequestIdempotency) -> Self {
120        self.idempotency = Some(idempotency);
121        self
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn safe_and_idempotent_methods_are_replayable() {
131        for method in [
132            Method::GET,
133            Method::HEAD,
134            Method::PUT,
135            Method::DELETE,
136            Method::OPTIONS,
137            Method::TRACE,
138        ] {
139            assert!(
140                RequestIdempotency::for_method(&method).is_idempotent(),
141                "{method} is idempotent per RFC 9110 §9.2.2"
142            );
143        }
144    }
145
146    /// `PATCH` is the one that reads as idempotent to intuition and
147    /// isn't — RFC 5789 §2 says so explicitly, and a retried partial
148    /// update is exactly the silent-corruption case this flag exists to
149    /// prevent.
150    #[test]
151    fn post_and_patch_are_not_replayable() {
152        for method in [Method::POST, Method::PATCH] {
153            assert!(
154                !RequestIdempotency::for_method(&method).is_idempotent(),
155                "{method} must not be retried without an idempotency key"
156            );
157        }
158    }
159
160    #[test]
161    fn constants_and_constructor_agree() {
162        assert_eq!(
163            RequestIdempotency::new(true),
164            RequestIdempotency::IDEMPOTENT
165        );
166        assert_eq!(
167            RequestIdempotency::new(false),
168            RequestIdempotency::NOT_IDEMPOTENT
169        );
170        assert!(RequestIdempotency::IDEMPOTENT.is_idempotent());
171        assert!(!RequestIdempotency::NOT_IDEMPOTENT.is_idempotent());
172    }
173}