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}