Skip to main content

rustlavel_cache/
idempotency.rs

1//! Idempotency keys: make `POST /payments` safe to retry.
2//!
3//! A client sends a payment, the network drops before the answer arrives, and
4//! now it does not know whether the payment happened. Retrying risks charging
5//! twice; not retrying risks not charging at all. The way out, which Stripe
6//! made standard, is for the client to name each attempt: an `Idempotency-Key`
7//! header, chosen by the client, that the server remembers. The first request
8//! with a key runs; every later one with the same key gets the first one's
9//! response back, without running anything.
10//!
11//! ```ignore
12//! r.group("/api", |api| {
13//!     api.middleware(Idempotency::new(&cache));
14//!     api.post("/payments", PaymentController::store);
15//! });
16//! ```
17//!
18//! Three things the client can see:
19//!
20//! - a replay carries `Idempotent-Replayed: true`, so a client can tell a
21//!   remembered answer from a fresh one;
22//! - the same key with a *different* request — another amount, another path —
23//!   is a `422`, because silently returning the old answer to a new question
24//!   is how a client ends up believing something that is not true;
25//! - a key whose first request is still running is a `409`, with
26//!   `Retry-After: 1`, rather than a second execution.
27//!
28//! Only 2xx and 4xx answers are remembered. A 5xx means the server failed,
29//! and the client's retry should get another go, not a copy of the failure.
30//!
31//! Keys are scoped to the caller — the `Authorization` header when there is
32//! one, otherwise the client address — so two tenants who both chose
33//! `order-1` do not collide. Change the scope with [`Idempotency::scope_by`]
34//! when the application has a better notion of who is calling.
35
36use crate::store::Cache;
37use rustlavel_core::Json;
38use rustlavel_http::handler::BoxFuture;
39use rustlavel_http::{Middleware, Method, Next, Request, Response, Status};
40use std::hash::{Hash, Hasher};
41use std::sync::Arc;
42use std::time::Duration;
43
44type ScopeFn = Arc<dyn Fn(&Request) -> String + Send + Sync>;
45
46#[derive(Clone)]
47pub struct Idempotency {
48    store: Arc<dyn Cache>,
49    header: String,
50    ttl: Duration,
51    scope: ScopeFn,
52    required: bool,
53}
54
55impl Idempotency {
56    /// Remember answers for 24 hours, which is Stripe's window and long
57    /// enough for any retry policy a client would plausibly run.
58    pub fn new(cache: &crate::CacheStore) -> Self {
59        Idempotency::with_driver(cache.driver_handle())
60    }
61
62    pub fn with_driver(store: Arc<dyn Cache>) -> Self {
63        Idempotency {
64            store,
65            header: "idempotency-key".to_string(),
66            ttl: Duration::from_secs(24 * 60 * 60),
67            scope: Arc::new(default_scope),
68            required: false,
69        }
70    }
71
72    /// Read the key from a different header.
73    pub fn header(mut self, name: &str) -> Self {
74        self.header = name.to_ascii_lowercase();
75        self
76    }
77
78    /// How long an answer is remembered.
79    pub fn remember_for(mut self, ttl: Duration) -> Self {
80        self.ttl = ttl;
81        self
82    }
83
84    /// Decide whose key it is — a user id from the auth middleware, a tenant.
85    pub fn scope_by(mut self, scope: impl Fn(&Request) -> String + Send + Sync + 'static) -> Self {
86        self.scope = Arc::new(scope);
87        self
88    }
89
90    /// Refuse a write that carries no key, with a 400 that says so.
91    ///
92    /// Off by default, because most endpoints are fine without one. On for a
93    /// payments API, where a client that forgets the header has a bug that
94    /// should be found in development rather than in the ledger.
95    pub fn required(mut self) -> Self {
96        self.required = true;
97        self
98    }
99}
100
101/// `Authorization` when present, since that names the caller; the address
102/// otherwise. Unknown callers share one scope rather than escaping it.
103fn default_scope(request: &Request) -> String {
104    if let Some(auth) = request.header("authorization") {
105        let mut hasher = std::hash::DefaultHasher::new();
106        auth.hash(&mut hasher);
107        return format!("auth:{:016x}", hasher.finish());
108    }
109    format!("ip:{}", request.ip().unwrap_or_else(|| "unknown".to_string()))
110}
111
112/// What a request *is*, so the same key with a different request is caught.
113fn fingerprint(request: &Request) -> String {
114    let mut hasher = std::hash::DefaultHasher::new();
115    request.method().as_str().hash(&mut hasher);
116    request.path().hash(&mut hasher);
117    request.body().hash(&mut hasher);
118    format!("{:016x}", hasher.finish())
119}
120
121fn key_looks_valid(key: &str) -> bool {
122    !key.is_empty() && key.len() <= 255 && key.bytes().all(|b| b.is_ascii_graphic())
123}
124
125/// A response flattened to JSON for the store, and back.
126///
127/// Bodies are kept as text when they are text, which for an API they nearly
128/// always are, and as hex when they are not. Hex rather than base64 because
129/// the cache crate has no base64 and a second copy of one would be a second
130/// place for it to be wrong.
131fn freeze(response: &Response, fingerprint: &str) -> Json {
132    let headers: Vec<Json> = response
133        .headers
134        .iter()
135        .map(|(name, value)| Json::Array(vec![Json::from(name), Json::from(value)]))
136        .collect();
137    let (encoding, body) = match std::str::from_utf8(&response.body) {
138        Ok(text) => ("utf8", text.to_string()),
139        Err(_) => ("hex", response.body.iter().map(|b| format!("{b:02x}")).collect()),
140    };
141    Json::object([
142        ("status", Json::from(i64::from(response.status.code()))),
143        ("headers", Json::Array(headers)),
144        ("encoding", Json::from(encoding)),
145        ("body", Json::from(body)),
146        ("fingerprint", Json::from(fingerprint)),
147    ])
148}
149
150fn thaw(frozen: &Json) -> Option<Response> {
151    let status = u16::try_from(frozen.get("status")?.as_i64()?).ok()?;
152    let mut response = Response::new(Status::from(status));
153    for pair in frozen.get("headers")?.as_array()? {
154        let pair = pair.as_array()?;
155        response.headers.append(pair.first()?.as_str()?, pair.get(1)?.as_str()?);
156    }
157    let body = frozen.get("body")?.as_str()?;
158    response.body = match frozen.get("encoding")?.as_str()? {
159        "hex" => body
160            .as_bytes()
161            .chunks(2)
162            .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok())
163            .collect::<Option<Vec<u8>>>()?,
164        _ => body.as_bytes().to_vec(),
165    };
166    Some(response)
167}
168
169fn problem(status: Status, message: &str) -> Response {
170    Response::new(status).with_json(Json::object([("message", Json::from(message))]))
171}
172
173impl Middleware for Idempotency {
174    fn handle(&self, request: Request, next: Next) -> BoxFuture<Response> {
175        // Reads are idempotent by definition; only a write needs remembering.
176        if matches!(request.method(), Method::Get | Method::Head | Method::Options) {
177            return next.run(request);
178        }
179
180        let key = match request.header(&self.header) {
181            Some(key) if key_looks_valid(key) => key.to_string(),
182            Some(_) => {
183                return Box::pin(async move {
184                    problem(
185                        Status::BAD_REQUEST,
186                        "The idempotency key must be 1–255 printable ASCII characters.",
187                    )
188                });
189            }
190            None if self.required => {
191                let header = self.header.clone();
192                return Box::pin(async move {
193                    problem(
194                        Status::BAD_REQUEST,
195                        &format!("This endpoint requires an {header} header on every write."),
196                    )
197                });
198            }
199            None => return next.run(request),
200        };
201
202        let store = Arc::clone(&self.store);
203        let ttl = self.ttl;
204        let scope = (self.scope)(&request);
205        let print = fingerprint(&request);
206        let lock_key = format!("idempotency:{scope}:{key}:lock");
207        let response_key = format!("idempotency:{scope}:{key}:response");
208
209        Box::pin(async move {
210            // The increment is atomic in every driver, which makes it a lock:
211            // exactly one caller sees 1 and runs; everyone else finds either a
212            // stored answer or a request still in flight.
213            let claim = match store.increment_within(&lock_key, 1, ttl).await {
214                Ok(n) => n,
215                // A store that is down must not stop the API. The request runs
216                // once, unprotected, which is what would have happened anyway.
217                Err(_) => return next.run(request).await,
218            };
219
220            if claim > 1 {
221                return match store.get(&response_key).await {
222                    Ok(Some(frozen)) => {
223                        if frozen.get("fingerprint").and_then(Json::as_str) != Some(print.as_str()) {
224                            return problem(
225                                Status::UNPROCESSABLE,
226                                "This idempotency key was already used for a different request.",
227                            );
228                        }
229                        match thaw(&frozen) {
230                            Some(response) => response.with_header("idempotent-replayed", "true"),
231                            None => problem(Status::INTERNAL_ERROR, "The remembered response could not be read."),
232                        }
233                    }
234                    Ok(None) => problem(
235                        Status::CONFLICT,
236                        "A request with this idempotency key is still being processed.",
237                    )
238                    .with_header("retry-after", "1"),
239                    Err(_) => next.run(request).await,
240                };
241            }
242
243            let response = next.run(request).await;
244
245            if response.status.code() >= 500 {
246                // Our failure, not the client's. Let the retry try again.
247                let _ = store.forget(&lock_key).await;
248            } else {
249                let _ = store.put(&response_key, freeze(&response, &print), ttl).await;
250            }
251            response
252        })
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::memory::MemoryStore;
260    use rustlavel_http::{Router, TestClient};
261    use std::sync::atomic::{AtomicUsize, Ordering};
262
263    fn store() -> Arc<dyn Cache> {
264        Arc::new(MemoryStore::new())
265    }
266
267    fn client(idempotency: Idempotency) -> (TestClient, Arc<AtomicUsize>) {
268        let executions = Arc::new(AtomicUsize::new(0));
269        let counter = executions.clone();
270        let mut router = Router::new();
271        router.middleware(idempotency);
272        router.post("/payments", move |req: Request| {
273            let counter = counter.clone();
274            async move {
275                let n = counter.fetch_add(1, Ordering::SeqCst) + 1;
276                Response::new(Status::CREATED)
277                    .with_json(Json::object([("execution", Json::from(n as i64)), ("body", Json::from(req.body_string()))]))
278                    .with_header("location", format!("/payments/{n}"))
279            }
280        });
281        router.post("/failing", |_req: Request| async { Response::new(Status::INTERNAL_ERROR).with_text("boom") });
282        router.post("/binary", |_req: Request| async {
283            Response::ok().with_header("content-type", "application/octet-stream").with_body(vec![0u8, 255, 1, 254])
284        });
285        router.get("/payments", |_req: Request| async { Response::text("list") });
286        (TestClient::new(router), executions)
287    }
288
289    fn post(path: &str, key: &str, body: &str) -> Request {
290        Request::new(Method::Post, path)
291            .with_header("idempotency-key", key)
292            .with_peer("10.0.0.1:44321".parse().expect("an address"))
293            .with_body(body.as_bytes().to_vec())
294    }
295
296    #[tokio::test]
297    async fn the_second_request_with_a_key_is_a_replay_not_an_execution() {
298        let (client, executions) = client(Idempotency::with_driver(store()));
299
300        let first = client.send(post("/payments", "order-1", "amount=10")).await;
301        let first = first.assert_status(201);
302        assert_eq!(first.header("idempotent-replayed"), None);
303
304        let second = client.send(post("/payments", "order-1", "amount=10")).await;
305        let second = second.assert_status(201);
306        assert_eq!(second.header("idempotent-replayed"), Some("true"));
307        assert_eq!(second.body(), first.body(), "byte for byte the first answer");
308        assert_eq!(second.header("location"), Some("/payments/1"), "headers come back too");
309        assert_eq!(executions.load(Ordering::SeqCst), 1);
310    }
311
312    #[tokio::test]
313    async fn a_different_key_is_a_different_request() {
314        let (client, executions) = client(Idempotency::with_driver(store()));
315        client.send(post("/payments", "order-1", "amount=10")).await;
316        client.send(post("/payments", "order-2", "amount=10")).await;
317        assert_eq!(executions.load(Ordering::SeqCst), 2);
318    }
319
320    #[tokio::test]
321    async fn the_same_key_with_a_different_body_is_refused() {
322        let (client, executions) = client(Idempotency::with_driver(store()));
323        client.send(post("/payments", "order-1", "amount=10")).await;
324
325        let response = client.send(post("/payments", "order-1", "amount=99")).await;
326        let response = response.assert_status(422);
327        assert!(response.body().contains("different request"));
328        assert_eq!(executions.load(Ordering::SeqCst), 1, "and nothing ran");
329    }
330
331    #[tokio::test]
332    async fn keys_are_scoped_to_the_caller() {
333        let (client, executions) = client(Idempotency::with_driver(store()));
334        client.send(post("/payments", "order-1", "amount=10")).await;
335
336        let other_tenant = post("/payments", "order-1", "amount=10").with_header("authorization", "Bearer other");
337        client.send(other_tenant).await.assert_status(201);
338        assert_eq!(executions.load(Ordering::SeqCst), 2, "same key, different caller, runs again");
339    }
340
341    #[tokio::test]
342    async fn a_custom_scope_is_honoured() {
343        let idempotency = Idempotency::with_driver(store()).scope_by(|req| req.header("x-tenant").unwrap_or("none").to_string());
344        let (client, executions) = client(idempotency);
345
346        client.send(post("/payments", "k", "a").with_header("x-tenant", "acme")).await;
347        client.send(post("/payments", "k", "a").with_header("x-tenant", "acme")).await;
348        client.send(post("/payments", "k", "a").with_header("x-tenant", "globex")).await;
349        assert_eq!(executions.load(Ordering::SeqCst), 2);
350    }
351
352    #[tokio::test]
353    async fn without_a_key_every_request_runs() {
354        let (client, executions) = client(Idempotency::with_driver(store()));
355        let plain = || Request::new(Method::Post, "/payments").with_body(b"x".to_vec());
356        client.send(plain()).await.assert_status(201);
357        client.send(plain()).await.assert_status(201);
358        assert_eq!(executions.load(Ordering::SeqCst), 2);
359    }
360
361    #[tokio::test]
362    async fn a_key_can_be_required() {
363        let (client, executions) = client(Idempotency::with_driver(store()).required());
364        let response = client.send(Request::new(Method::Post, "/payments")).await;
365        let response = response.assert_status(400);
366        assert!(response.body().contains("idempotency-key"));
367        assert_eq!(executions.load(Ordering::SeqCst), 0);
368    }
369
370    #[tokio::test]
371    async fn a_malformed_key_is_a_400() {
372        let (client, _) = client(Idempotency::with_driver(store()));
373        client.send(post("/payments", "has space", "x")).await.assert_status(400);
374        client.send(post("/payments", &"k".repeat(256), "x")).await.assert_status(400);
375    }
376
377    #[tokio::test]
378    async fn reads_are_never_touched() {
379        let (client, _) = client(Idempotency::with_driver(store()).required());
380        let response = client.send(Request::new(Method::Get, "/payments")).await;
381        let response = response.assert_ok();
382        assert_eq!(response.body(), "list");
383    }
384
385    #[tokio::test]
386    async fn a_server_error_is_not_remembered_so_the_retry_runs() {
387        let (client, _) = client(Idempotency::with_driver(store()));
388        client.send(post("/failing", "k", "x")).await.assert_status(500);
389        let retry = client.send(post("/failing", "k", "x")).await;
390        let retry = retry.assert_status(500);
391        assert_eq!(retry.header("idempotent-replayed"), None, "ran again rather than replayed");
392    }
393
394    #[tokio::test]
395    async fn a_request_still_in_flight_is_a_409() {
396        let store = store();
397        // Take the lock the way a first request would, but never store an answer.
398        store.increment_within("idempotency:ip:10.0.0.1:k:lock", 1, Duration::from_secs(60)).await.unwrap();
399        let (client, executions) = client(Idempotency::with_driver(store));
400
401        let response = client.send(post("/payments", "k", "x")).await;
402        let response = response.assert_status(409);
403        assert_eq!(response.header("retry-after"), Some("1"));
404        assert_eq!(executions.load(Ordering::SeqCst), 0);
405    }
406
407    #[tokio::test]
408    async fn binary_bodies_survive_the_round_trip() {
409        let (client, _) = client(Idempotency::with_driver(store()));
410        client.send(post("/binary", "k", "x")).await.assert_ok();
411        let replay = client.send(post("/binary", "k", "x")).await;
412        assert_eq!(replay.header("idempotent-replayed"), Some("true"));
413        assert_eq!(replay.body_bytes(), &[0u8, 255, 1, 254]);
414    }
415
416    #[tokio::test]
417    async fn the_answer_expires_with_the_ttl() {
418        let idempotency = Idempotency::with_driver(store()).remember_for(Duration::from_millis(30));
419        let (client, executions) = client(idempotency);
420        client.send(post("/payments", "k", "x")).await;
421        tokio::time::sleep(Duration::from_millis(60)).await;
422        client.send(post("/payments", "k", "x")).await;
423        assert_eq!(executions.load(Ordering::SeqCst), 2, "forgotten, so it ran again");
424    }
425
426    #[test]
427    fn freezing_and_thawing_keeps_status_headers_and_body() {
428        let original = Response::new(Status::CREATED)
429            .with_header("location", "/x/1")
430            .with_header("content-type", "application/json")
431            .with_body(b"{\"a\":1}".to_vec());
432        let thawed = thaw(&freeze(&original, "fp")).expect("readable");
433        assert_eq!(thawed.status, Status::CREATED);
434        assert_eq!(thawed.headers.get("location"), Some("/x/1"));
435        assert_eq!(thawed.body, original.body);
436    }
437}