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 or a handler that was cancelled releases the
15//!   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::{BuildHasher, 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(100_000)
108    }
109}
110
111impl MemoryStore {
112    /// Keep at most `max_entries` keys; 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    required: bool,
184    scope: Arc<ScopeFn>,
185    hasher: std::collections::hash_map::RandomState,
186}
187
188impl Default for Idempotency {
189    fn default() -> Self {
190        Self::new()
191    }
192}
193
194impl Idempotency {
195    /// In-memory store, `POST` and `PATCH`, results kept for 24 hours
196    pub fn new() -> Self {
197        Self::with_store(MemoryStore::default())
198    }
199
200    pub fn with_store(store: impl IdempotencyStore) -> Self {
201        let hasher = std::collections::hash_map::RandomState::new();
202        let scope_hasher = hasher.clone();
203        Self {
204            store: Arc::new(store),
205            header: "idempotency-key".into(),
206            methods: vec![Method::POST, Method::PATCH],
207            ttl: Duration::from_secs(24 * 3600),
208            lock_ttl: Duration::from_secs(60),
209            required: false,
210            scope: Arc::new(move |req| {
211                let mut h = scope_hasher.build_hasher();
212                for name in ["authorization", "cookie"] {
213                    req.headers.get(name).map(|v| v.as_bytes()).hash(&mut h);
214                }
215                format!("{:016x}", h.finish())
216            }),
217            hasher,
218        }
219    }
220
221    /// Methods the key applies to (default `POST` and `PATCH`; `PUT` and `DELETE`
222    /// are idempotent by definition)
223    pub fn methods<I: IntoIterator<Item = Method>>(mut self, methods: I) -> Self {
224        self.methods = methods.into_iter().collect();
225        self
226    }
227
228    /// How long a finished response can be replayed (default 24 hours)
229    pub fn ttl(mut self, ttl: Duration) -> Self {
230        self.ttl = ttl;
231        self
232    }
233
234    /// How long a running request blocks retries if it never finishes (default 60 seconds)
235    pub fn lock_ttl(mut self, lock_ttl: Duration) -> Self {
236        self.lock_ttl = lock_ttl;
237        self
238    }
239
240    /// Answer `400` when the header is missing instead of running the handler
241    /// without protection
242    pub fn required(mut self) -> Self {
243        self.required = true;
244        self
245    }
246
247    /// Header carrying the key (default `Idempotency-Key`)
248    pub fn header(mut self, name: &str) -> Self {
249        self.header = name.to_ascii_lowercase();
250        self
251    }
252
253    /// Whose keys these are. Keys of different scopes never collide. The default
254    /// is the `Authorization` + `Cookie` headers; use a user or tenant id when you have one.
255    pub fn scope_by<F>(mut self, f: F) -> Self
256    where
257        F: Fn(&Request) -> String + Send + Sync + 'static,
258    {
259        self.scope = Arc::new(f);
260        self
261    }
262
263    fn fingerprint(&self, req: &Request) -> u64 {
264        let mut h = self.hasher.build_hasher();
265        req.method.as_str().hash(&mut h);
266        req.uri.path().hash(&mut h);
267        req.uri.query().hash(&mut h);
268        req.body.hash(&mut h);
269        h.finish()
270    }
271}
272
273/// Releases a claim if the request future is dropped before it finishes
274struct Claim {
275    store: Arc<dyn IdempotencyStore>,
276    key: String,
277    armed: bool,
278}
279
280impl Drop for Claim {
281    fn drop(&mut self) {
282        if self.armed {
283            let (store, key) = (self.store.clone(), std::mem::take(&mut self.key));
284            if let Ok(handle) = tokio::runtime::Handle::try_current() {
285                handle.spawn(async move { store.abandon(&key).await });
286            }
287        }
288    }
289}
290
291fn valid_key(key: &str) -> bool {
292    !key.is_empty() && key.len() <= 255 && key.bytes().all(|b| (0x21..=0x7e).contains(&b))
293}
294
295#[async_trait]
296impl Middleware for Idempotency {
297    async fn handle(&self, req: Request, next: Next) -> Result<Response> {
298        if !self.methods.contains(&req.method) {
299            return next.run(req).await;
300        }
301        let key = match req.headers.get(self.header.as_str()) {
302            None if self.required => {
303                return Ok(Response::from_error(Error::bad_request(format!(
304                    "{} header is required",
305                    self.header
306                ))))
307            }
308            None => return next.run(req).await,
309            Some(v) => match v.to_str().ok().filter(|k| valid_key(k)) {
310                Some(k) => k.to_string(),
311                None => {
312                    return Ok(Response::from_error(Error::bad_request(format!(
313                        "{} must be 1-255 visible ASCII characters",
314                        self.header
315                    ))))
316                }
317            },
318        };
319
320        let store_key = format!("{}|{}", (self.scope)(&req), key);
321        let fingerprint = self.fingerprint(&req);
322        match self
323            .store
324            .begin(&store_key, fingerprint, self.lock_ttl)
325            .await
326        {
327            Begin::Started => {}
328            Begin::InFlight => {
329                return Ok(Response::from_error(Error::custom(
330                    409,
331                    "A request with this idempotency key is still being processed",
332                ))
333                .with_header("retry-after", "1"))
334            }
335            Begin::Mismatch => {
336                return Ok(Response::from_error(Error::custom(
337                    422,
338                    "Idempotency key was already used with a different request",
339                )))
340            }
341            Begin::Unavailable => {
342                return Ok(Response::from_error(Error::custom(
343                    503,
344                    "Idempotency store is full, try again shortly",
345                ))
346                .with_header("retry-after", "1"))
347            }
348            Begin::Replay(stored) => return Ok(replay(stored)),
349        }
350
351        let mut claim = Claim {
352            store: self.store.clone(),
353            key: store_key.clone(),
354            armed: true,
355        };
356        let res = next.run(req).await.unwrap_or_else(Response::from_error);
357        if res.status.is_server_error() || res.is_streaming() {
358            return Ok(res); // claim drops armed: released for a retry
359        }
360        claim.armed = false;
361        let stored = StoredResponse {
362            status: res.status.as_u16(),
363            headers: res
364                .headers
365                .iter()
366                .filter(|(n, _)| *n != http::header::SET_COOKIE)
367                .map(|(n, v)| (n.as_str().to_string(), v.as_bytes().to_vec()))
368                .collect(),
369            body: res.body.clone(),
370        };
371        self.store.complete(&store_key, stored, self.ttl).await;
372        Ok(res)
373    }
374}
375
376fn replay(stored: StoredResponse) -> Response {
377    let mut res =
378        Response::new(http::StatusCode::from_u16(stored.status).unwrap_or(http::StatusCode::OK))
379            .with_body(stored.body);
380    for (name, value) in stored.headers {
381        if let (Ok(n), Ok(v)) = (
382            http::HeaderName::from_bytes(name.as_bytes()),
383            http::HeaderValue::from_bytes(&value),
384        ) {
385            res.headers.append(n, v);
386        }
387    }
388    res.with_header("idempotency-replayed", "true")
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    fn done() -> StoredResponse {
396        StoredResponse {
397            status: 201,
398            headers: vec![],
399            body: Bytes::from_static(b"x"),
400        }
401    }
402
403    #[tokio::test]
404    async fn lifecycle_in_memory() {
405        let s = MemoryStore::default();
406        let ttl = Duration::from_secs(10);
407        assert!(matches!(s.begin("k", 1, ttl).await, Begin::Started));
408        assert!(matches!(s.begin("k", 1, ttl).await, Begin::InFlight));
409        assert!(matches!(s.begin("k", 2, ttl).await, Begin::Mismatch));
410        s.complete("k", done(), ttl).await;
411        assert!(matches!(s.begin("k", 1, ttl).await, Begin::Replay(r) if r.status == 201));
412        assert!(matches!(s.begin("k", 2, ttl).await, Begin::Mismatch));
413    }
414
415    #[tokio::test]
416    async fn abandon_frees_running_but_not_finished_keys() {
417        let s = MemoryStore::default();
418        let ttl = Duration::from_secs(10);
419        s.begin("a", 1, ttl).await;
420        s.abandon("a").await;
421        assert!(matches!(s.begin("a", 1, ttl).await, Begin::Started));
422        s.complete("a", done(), ttl).await;
423        s.abandon("a").await;
424        assert!(matches!(s.begin("a", 1, ttl).await, Begin::Replay(_)));
425    }
426
427    #[test]
428    fn expired_claims_and_results_lapse() {
429        let s = MemoryStore::default();
430        let t = Instant::now();
431        assert!(matches!(
432            s.begin_at("k", 1, Duration::from_secs(5), t),
433            Begin::Started
434        ));
435        assert!(matches!(
436            s.begin_at("k", 1, Duration::from_secs(5), t + Duration::from_secs(6)),
437            Begin::Started
438        ));
439    }
440
441    #[test]
442    fn full_store_refuses_new_keys_until_something_expires() {
443        let s = MemoryStore::new(1);
444        let t = Instant::now();
445        let ttl = Duration::from_secs(5);
446        assert!(matches!(s.begin_at("a", 1, ttl, t), Begin::Started));
447        assert!(matches!(s.begin_at("b", 1, ttl, t), Begin::Unavailable));
448        assert!(matches!(
449            s.begin_at("b", 1, ttl, t + Duration::from_secs(6)),
450            Begin::Started
451        ));
452    }
453
454    #[test]
455    fn key_validation() {
456        assert!(valid_key("3f9c-aa"));
457        assert!(!valid_key(""));
458        assert!(!valid_key("has space"));
459        assert!(!valid_key(&"x".repeat(256)));
460        assert!(!valid_key("ümlaut"));
461    }
462}