1use 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 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 pub fn header(mut self, name: &str) -> Self {
74 self.header = name.to_ascii_lowercase();
75 self
76 }
77
78 pub fn remember_for(mut self, ttl: Duration) -> Self {
80 self.ttl = ttl;
81 self
82 }
83
84 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 pub fn required(mut self) -> Self {
96 self.required = true;
97 self
98 }
99}
100
101fn 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
112fn 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
125fn 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 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 let claim = match store.increment_within(&lock_key, 1, ttl).await {
214 Ok(n) => n,
215 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 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 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}