Skip to main content

ullun/
idempotency.rs

1//! Idempotency keys for unsafe requests
2//!
3//! Networks retry. A client that times out on `POST /payments` cannot know
4//! whether the payment happened, and resending may charge twice. With the
5//! [`Idempotency`] middleware the client sends an `Idempotency-Key` header
6//! (a random string it reuses for retries of the same operation) and the server
7//! runs the handler only once:
8//!
9//! * first request: handler runs, a successful (non-5xx, non-streaming) response is stored
10//! * retry with the same key and payload: the stored response is replayed
11//!   with `Idempotency-Replayed: true`, the handler does not run again
12//! * retry while the first is still running: `409 Conflict` + `Retry-After`
13//! * same key, different payload (method, path, query or body): `422`
14//! * a `5xx`, a streaming response, a body over [`Idempotency::max_response_bytes`]
15//!   or a handler that was cancelled releases the key, so a retry can try again
16//!
17//! Keys are scoped to the caller: by default to the `Authorization` and `Cookie`
18//! headers, so one user cannot replay (and read) another user's response.
19//! Change it with [`Idempotency::scope_by`], e.g. to a user id.
20//!
21//! ```no_run
22//! use ullun::idempotency::Idempotency;
23//! use ullun::prelude::*;
24//!
25//! # async fn run() {
26//! App::new()
27//!     .middleware(Idempotency::new())
28//!     .post("/payments", |_req: Request| async {
29//!         Ok(Response::json(serde_json::json!({ "charged": true })))
30//!     })
31//!     .run("127.0.0.1:3000")
32//!     .await
33//!     .unwrap();
34//! # }
35//! ```
36//!
37//! The default store lives in memory of one process. Implement
38//! [`IdempotencyStore`] on top of your database or Redis when several instances
39//! serve the same clients.
40
41use crate::error::{Error, Result};
42use crate::middleware::{Middleware, Next};
43use crate::request::Request;
44use crate::response::Response;
45use async_trait::async_trait;
46use bytes::Bytes;
47use http::Method;
48use std::collections::HashMap;
49use std::hash::{DefaultHasher, Hash, Hasher};
50use std::sync::{Arc, Mutex};
51use std::time::{Duration, Instant};
52
53/// A response kept for replay
54#[derive(Clone, Debug)]
55pub struct StoredResponse {
56    pub status: u16,
57    pub headers: Vec<(String, Vec<u8>)>,
58    pub body: Bytes,
59}
60
61/// What the store says about a key
62#[derive(Debug)]
63pub enum Begin {
64    /// New key: the caller now owns it and must call `complete` or `abandon`
65    Started,
66    /// Another request with this key is running
67    InFlight,
68    /// This key finished before with the same request
69    Replay(StoredResponse),
70    /// This key was used for a different request
71    Mismatch,
72    /// The store cannot take more keys right now
73    Unavailable,
74}
75
76/// Where idempotency keys live
77#[async_trait]
78pub trait IdempotencyStore: Send + Sync + 'static {
79    /// Claim `key` for a request with this `fingerprint`. A claim that is never
80    /// completed must lapse after `lock_ttl`.
81    async fn begin(&self, key: &str, fingerprint: u64, lock_ttl: Duration) -> Begin;
82    /// Keep `response` for `ttl`
83    async fn complete(&self, key: &str, response: StoredResponse, ttl: Duration);
84    /// Forget a claim so the request can be retried
85    async fn abandon(&self, key: &str);
86}
87
88enum State {
89    Running,
90    Done(StoredResponse),
91}
92
93struct Entry {
94    fingerprint: u64,
95    state: State,
96    expires: Instant,
97}
98
99/// Process-local store, the default
100pub struct MemoryStore {
101    entries: Mutex<HashMap<String, Entry>>,
102    max_entries: usize,
103}
104
105impl Default for MemoryStore {
106    fn default() -> Self {
107        Self::new(10_000)
108    }
109}
110
111impl MemoryStore {
112    /// Keep at most `max_entries` keys (default 10 000); when full of live keys new ones are refused (`503`)
113    pub fn new(max_entries: usize) -> Self {
114        Self {
115            entries: Mutex::new(HashMap::new()),
116            max_entries,
117        }
118    }
119
120    fn begin_at(&self, key: &str, fingerprint: u64, lock_ttl: Duration, now: Instant) -> Begin {
121        let mut map = self.entries.lock().unwrap_or_else(|e| e.into_inner());
122        if let Some(entry) = map.get(key) {
123            if entry.expires > now {
124                return if entry.fingerprint != fingerprint {
125                    Begin::Mismatch
126                } else {
127                    match &entry.state {
128                        State::Running => Begin::InFlight,
129                        State::Done(res) => Begin::Replay(res.clone()),
130                    }
131                };
132            }
133        }
134        if map.len() >= self.max_entries && !map.contains_key(key) {
135            map.retain(|_, e| e.expires > now);
136            if map.len() >= self.max_entries {
137                return Begin::Unavailable;
138            }
139        }
140        map.insert(
141            key.to_string(),
142            Entry {
143                fingerprint,
144                state: State::Running,
145                expires: now + lock_ttl,
146            },
147        );
148        Begin::Started
149    }
150}
151
152#[async_trait]
153impl IdempotencyStore for MemoryStore {
154    async fn begin(&self, key: &str, fingerprint: u64, lock_ttl: Duration) -> Begin {
155        self.begin_at(key, fingerprint, lock_ttl, Instant::now())
156    }
157
158    async fn complete(&self, key: &str, response: StoredResponse, ttl: Duration) {
159        let mut map = self.entries.lock().unwrap_or_else(|e| e.into_inner());
160        if let Some(entry) = map.get_mut(key) {
161            entry.state = State::Done(response);
162            entry.expires = Instant::now() + ttl;
163        }
164    }
165
166    async fn abandon(&self, key: &str) {
167        let mut map = self.entries.lock().unwrap_or_else(|e| e.into_inner());
168        if matches!(map.get(key).map(|e| &e.state), Some(State::Running)) {
169            map.remove(key);
170        }
171    }
172}
173
174type ScopeFn = dyn Fn(&Request) -> String + Send + Sync;
175
176/// Idempotency-key middleware, see the [module documentation](self)
177pub struct Idempotency {
178    store: Arc<dyn IdempotencyStore>,
179    header: String,
180    methods: Vec<Method>,
181    ttl: Duration,
182    lock_ttl: Duration,
183    max_response: usize,
184    required: bool,
185    /// `None`: the `Authorization` + `Cookie` headers
186    scope: Option<Arc<ScopeFn>>,
187    /// Mixed into every hash; random per process unless set with [`Idempotency::shared_seed`]
188    seed: Vec<u8>,
189}
190
191impl Default for Idempotency {
192    fn default() -> Self {
193        Self::new()
194    }
195}
196
197impl Idempotency {
198    /// In-memory store, `POST` and `PATCH`, results kept for 24 hours
199    pub fn new() -> Self {
200        Self::with_store(MemoryStore::default())
201    }
202
203    pub fn with_store(store: impl IdempotencyStore) -> Self {
204        // unguessable per process, so clients cannot craft colliding scopes
205        let seed = crate::security::random_bytes(16).unwrap_or_else(|_| {
206            let state = std::collections::hash_map::RandomState::new();
207            let mut h = std::hash::BuildHasher::build_hasher(&state);
208            h.write_u8(0);
209            h.finish().to_le_bytes().to_vec()
210        });
211        Self {
212            store: Arc::new(store),
213            header: "idempotency-key".into(),
214            methods: vec![Method::POST, Method::PATCH],
215            ttl: Duration::from_secs(24 * 3600),
216            lock_ttl: Duration::from_secs(60),
217            max_response: 256 * 1024,
218            required: false,
219            scope: None,
220            seed,
221        }
222    }
223
224    /// Secret that makes the hashes behind scopes and payload fingerprints
225    /// identical on every instance. Required with a store shared between
226    /// processes (Redis): by default each process hashes with its own random
227    /// seed, and instances would disagree about whose key is whose. Keep it
228    /// secret and at least 16 bytes: it keeps clients from crafting colliding
229    /// scopes.
230    pub fn shared_seed(mut self, seed: impl AsRef<[u8]>) -> Self {
231        self.seed = seed.as_ref().to_vec();
232        self
233    }
234
235    fn hasher(&self) -> DefaultHasher {
236        let mut h = DefaultHasher::new();
237        h.write(&self.seed);
238        h
239    }
240
241    fn scope_of(&self, req: &Request) -> String {
242        match &self.scope {
243            Some(f) => f(req),
244            None => {
245                let mut h = self.hasher();
246                for name in ["authorization", "cookie"] {
247                    req.headers.get(name).map(|v| v.as_bytes()).hash(&mut h);
248                }
249                format!("{:016x}", h.finish())
250            }
251        }
252    }
253
254    /// Methods the key applies to (default `POST` and `PATCH`; `PUT` and `DELETE`
255    /// are idempotent by definition)
256    pub fn methods<I: IntoIterator<Item = Method>>(mut self, methods: I) -> Self {
257        self.methods = methods.into_iter().collect();
258        self
259    }
260
261    /// How long a finished response can be replayed (default 24 hours)
262    pub fn ttl(mut self, ttl: Duration) -> Self {
263        self.ttl = ttl;
264        self
265    }
266
267    /// How long a running request blocks retries if it never finishes (default 60 seconds)
268    pub fn lock_ttl(mut self, lock_ttl: Duration) -> Self {
269        self.lock_ttl = lock_ttl;
270        self
271    }
272
273    /// Largest response body that is kept for replay (default 256 KiB). Larger
274    /// responses are sent normally but not stored, so a retry runs the handler again.
275    pub fn max_response_bytes(mut self, max: usize) -> Self {
276        self.max_response = max;
277        self
278    }
279
280    /// Answer `400` when the header is missing instead of running the handler
281    /// without protection
282    pub fn required(mut self) -> Self {
283        self.required = true;
284        self
285    }
286
287    /// Header carrying the key (default `Idempotency-Key`)
288    pub fn header(mut self, name: &str) -> Self {
289        self.header = name.to_ascii_lowercase();
290        self
291    }
292
293    /// Whose keys these are. Keys of different scopes never collide. The default
294    /// is the `Authorization` + `Cookie` headers; use a user or tenant id when you have one.
295    pub fn scope_by<F>(mut self, f: F) -> Self
296    where
297        F: Fn(&Request) -> String + Send + Sync + 'static,
298    {
299        self.scope = Some(Arc::new(f));
300        self
301    }
302
303    fn fingerprint(&self, req: &Request) -> u64 {
304        let mut h = self.hasher();
305        req.method.as_str().hash(&mut h);
306        req.uri.path().hash(&mut h);
307        req.uri.query().hash(&mut h);
308        req.body.hash(&mut h);
309        h.finish()
310    }
311}
312
313/// Releases a claim if the request future is dropped before it finishes
314struct Claim {
315    store: Arc<dyn IdempotencyStore>,
316    key: String,
317    armed: bool,
318}
319
320impl Drop for Claim {
321    fn drop(&mut self) {
322        if self.armed {
323            let (store, key) = (self.store.clone(), std::mem::take(&mut self.key));
324            if let Ok(handle) = tokio::runtime::Handle::try_current() {
325                handle.spawn(async move { store.abandon(&key).await });
326            }
327        }
328    }
329}
330
331fn valid_key(key: &str) -> bool {
332    !key.is_empty() && key.len() <= 255 && key.bytes().all(|b| (0x21..=0x7e).contains(&b))
333}
334
335#[async_trait]
336impl Middleware for Idempotency {
337    async fn handle(&self, req: Request, next: Next) -> Result<Response> {
338        if !self.methods.contains(&req.method) {
339            return next.run(req).await;
340        }
341        let key = match req.headers.get(self.header.as_str()) {
342            None if self.required => {
343                return Ok(Response::from_error(Error::bad_request(format!(
344                    "{} header is required",
345                    self.header
346                ))))
347            }
348            None => return next.run(req).await,
349            Some(v) => match v.to_str().ok().filter(|k| valid_key(k)) {
350                Some(k) => k.to_string(),
351                None => {
352                    return Ok(Response::from_error(Error::bad_request(format!(
353                        "{} must be 1-255 visible ASCII characters",
354                        self.header
355                    ))))
356                }
357            },
358        };
359
360        let store_key = format!("{}|{}", self.scope_of(&req), key);
361        let fingerprint = self.fingerprint(&req);
362        match self
363            .store
364            .begin(&store_key, fingerprint, self.lock_ttl)
365            .await
366        {
367            Begin::Started => {}
368            Begin::InFlight => {
369                return Ok(Response::from_error(Error::custom(
370                    409,
371                    "A request with this idempotency key is still being processed",
372                ))
373                .with_header("retry-after", "1"))
374            }
375            Begin::Mismatch => {
376                return Ok(Response::from_error(Error::custom(
377                    422,
378                    "Idempotency key was already used with a different request",
379                )))
380            }
381            Begin::Unavailable => {
382                return Ok(Response::from_error(Error::custom(
383                    503,
384                    "Idempotency store is full, try again shortly",
385                ))
386                .with_header("retry-after", "1"))
387            }
388            Begin::Replay(stored) => return Ok(replay(stored)),
389        }
390
391        let mut claim = Claim {
392            store: self.store.clone(),
393            key: store_key.clone(),
394            armed: true,
395        };
396        let res = next.run(req).await.unwrap_or_else(Response::from_error);
397        if res.status.is_server_error() || res.is_streaming() || res.body.len() > self.max_response
398        {
399            return Ok(res); // claim drops armed: released for a retry
400        }
401        claim.armed = false;
402        let stored = StoredResponse {
403            status: res.status.as_u16(),
404            headers: res
405                .headers
406                .iter()
407                .filter(|(n, _)| *n != http::header::SET_COOKIE)
408                .map(|(n, v)| (n.as_str().to_string(), v.as_bytes().to_vec()))
409                .collect(),
410            body: res.body.clone(),
411        };
412        self.store.complete(&store_key, stored, self.ttl).await;
413        Ok(res)
414    }
415}
416
417fn replay(stored: StoredResponse) -> Response {
418    let mut res =
419        Response::new(http::StatusCode::from_u16(stored.status).unwrap_or(http::StatusCode::OK))
420            .with_body(stored.body);
421    for (name, value) in stored.headers {
422        if let (Ok(n), Ok(v)) = (
423            http::HeaderName::from_bytes(name.as_bytes()),
424            http::HeaderValue::from_bytes(&value),
425        ) {
426            res.headers.append(n, v);
427        }
428    }
429    res.with_header("idempotency-replayed", "true")
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    fn done() -> StoredResponse {
437        StoredResponse {
438            status: 201,
439            headers: vec![],
440            body: Bytes::from_static(b"x"),
441        }
442    }
443
444    #[tokio::test]
445    async fn lifecycle_in_memory() {
446        let s = MemoryStore::default();
447        let ttl = Duration::from_secs(10);
448        assert!(matches!(s.begin("k", 1, ttl).await, Begin::Started));
449        assert!(matches!(s.begin("k", 1, ttl).await, Begin::InFlight));
450        assert!(matches!(s.begin("k", 2, ttl).await, Begin::Mismatch));
451        s.complete("k", done(), ttl).await;
452        assert!(matches!(s.begin("k", 1, ttl).await, Begin::Replay(r) if r.status == 201));
453        assert!(matches!(s.begin("k", 2, ttl).await, Begin::Mismatch));
454    }
455
456    #[tokio::test]
457    async fn abandon_frees_running_but_not_finished_keys() {
458        let s = MemoryStore::default();
459        let ttl = Duration::from_secs(10);
460        s.begin("a", 1, ttl).await;
461        s.abandon("a").await;
462        assert!(matches!(s.begin("a", 1, ttl).await, Begin::Started));
463        s.complete("a", done(), ttl).await;
464        s.abandon("a").await;
465        assert!(matches!(s.begin("a", 1, ttl).await, Begin::Replay(_)));
466    }
467
468    #[test]
469    fn expired_claims_and_results_lapse() {
470        let s = MemoryStore::default();
471        let t = Instant::now();
472        assert!(matches!(
473            s.begin_at("k", 1, Duration::from_secs(5), t),
474            Begin::Started
475        ));
476        assert!(matches!(
477            s.begin_at("k", 1, Duration::from_secs(5), t + Duration::from_secs(6)),
478            Begin::Started
479        ));
480    }
481
482    #[test]
483    fn full_store_refuses_new_keys_until_something_expires() {
484        let s = MemoryStore::new(1);
485        let t = Instant::now();
486        let ttl = Duration::from_secs(5);
487        assert!(matches!(s.begin_at("a", 1, ttl, t), Begin::Started));
488        assert!(matches!(s.begin_at("b", 1, ttl, t), Begin::Unavailable));
489        assert!(matches!(
490            s.begin_at("b", 1, ttl, t + Duration::from_secs(6)),
491            Begin::Started
492        ));
493    }
494
495    #[test]
496    fn key_validation() {
497        assert!(valid_key("3f9c-aa"));
498        assert!(!valid_key(""));
499        assert!(!valid_key("has space"));
500        assert!(!valid_key(&"x".repeat(256)));
501        assert!(!valid_key("ümlaut"));
502    }
503}