1use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::{Arc, Mutex};
11
12use axum::body::{Body, Bytes};
13use axum::extract::{Request, State};
14use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
15use axum::middleware::Next;
16use axum::response::{IntoResponse, Response};
17use sha2::{Digest, Sha256};
18use tokio_stream::StreamExt;
19
20pub const HEADER: &str = "idempotency-key";
21
22pub const REPLAYED: &str = "idempotent-replayed";
24
25const KEEP: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
27
28const SWEEP_EVERY: std::time::Duration = std::time::Duration::from_secs(3600);
30
31fn past_keep(t: std::time::SystemTime) -> bool {
33 t.elapsed().is_ok_and(|age| age > KEEP)
34}
35
36const MAX_BODY: usize = 256 << 20;
38
39fn kept(name: &HeaderName) -> bool {
41 !matches!(name.as_str(), "content-length" | "transfer-encoding" | "connection")
42}
43
44pub struct Records {
46 dir: Option<PathBuf>,
47 live: Mutex<HashMap<String, Arc<Record>>>,
48 swept: Mutex<Option<std::time::Instant>>,
50}
51
52struct Record {
53 request: [u8; 32],
54 state: Mutex<Recording>,
55 changed: tokio::sync::Notify,
56 done_at: Mutex<Option<std::time::SystemTime>>,
59}
60
61impl Record {
62 fn new(request: [u8; 32], recording: Recording) -> Arc<Record> {
63 Arc::new(Record { request, state: Mutex::new(recording), changed: tokio::sync::Notify::new(), done_at: Mutex::new(None) })
64 }
65
66 fn expired(&self) -> bool {
67 self.done_at.lock().expect("record").is_some_and(past_keep)
68 }
69}
70
71#[derive(Default, serde::Serialize, serde::Deserialize)]
72struct Recording {
73 status: Option<u16>,
74 headers: Vec<(String, String)>,
75 #[serde(skip)]
76 body: Vec<u8>,
77 done: bool,
78}
79
80#[derive(serde::Serialize, serde::Deserialize)]
81struct Stored {
82 request: String,
83 #[serde(flatten)]
84 recording: Recording,
85}
86
87impl Records {
88 pub fn new(dir: Option<PathBuf>) -> Records {
90 let swept = Mutex::new(dir.is_some().then(std::time::Instant::now));
91 let records = Records { dir, live: Mutex::new(HashMap::new()), swept };
92 if let Some(dir) = &records.dir {
93 sweep(dir, &records.live);
94 }
95 records
96 }
97
98 fn sweep_when_due(self: &Arc<Self>) {
101 let Some(dir) = self.dir.clone() else { return };
102 {
103 let mut swept = self.swept.lock().expect("records");
104 if swept.is_some_and(|t| t.elapsed() < SWEEP_EVERY) {
105 return;
106 }
107 *swept = Some(std::time::Instant::now());
108 }
109 let records = Arc::clone(self);
110 tokio::task::spawn_blocking(move || sweep(&dir, &records.live));
111 }
112
113 fn paths(&self, id: &str) -> Option<(PathBuf, PathBuf)> {
114 self.dir.as_ref().map(|d| (d.join(format!("{id}.json")), d.join(format!("{id}.body"))))
115 }
116
117 fn stored(&self, id: &str) -> Option<(String, Recording)> {
119 let (meta, body) = self.paths(id)?;
120 if std::fs::metadata(&meta).and_then(|m| m.modified()).is_ok_and(past_keep) {
121 return None;
122 }
123 let stored: Stored = serde_json::from_slice(&std::fs::read(meta).ok()?).ok()?;
124 if !stored.recording.done {
125 return None;
126 }
127 let mut recording = stored.recording;
128 recording.body = std::fs::read(body).ok()?;
129 Some((stored.request, recording))
130 }
131
132 fn persist(&self, id: &str, rec: &Record, recording: &Recording) {
133 let Some((meta, _)) = self.paths(id) else { return };
134 let stored = Stored {
135 request: hex(&rec.request),
136 recording: Recording { status: recording.status, headers: recording.headers.clone(), body: Vec::new(), done: recording.done },
137 };
138 if let Ok(bytes) = serde_json::to_vec(&stored) {
139 let _ = std::fs::write(meta, bytes);
140 }
141 }
142
143 fn append(&self, id: &str, bytes: &[u8]) {
144 let Some((_, body)) = self.paths(id) else { return };
145 use std::io::Write;
146 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(body) {
147 let _ = f.write_all(bytes);
148 }
149 }
150
151 fn start(&self, id: &str) {
152 let Some((meta, body)) = self.paths(id) else { return };
153 if let Some(dir) = meta.parent() {
154 let _ = std::fs::create_dir_all(dir);
155 }
156 let _ = std::fs::remove_file(meta);
157 let _ = std::fs::remove_file(body);
158 }
159}
160
161fn sweep(dir: &std::path::Path, live: &Mutex<HashMap<String, Arc<Record>>>) {
166 let Ok(rd) = std::fs::read_dir(dir) else { return };
167 for e in rd.flatten() {
168 if e.metadata().and_then(|m| m.modified()).is_ok_and(past_keep) {
169 let path = e.path();
170 #[cfg(test)]
171 if let Some(between) = AFTER_FIRST_LOOK.with(std::cell::Cell::get) {
172 between(&path);
173 }
174 let live = live.lock().expect("records");
175 if path.file_stem().and_then(|s| s.to_str()).is_some_and(|id| live.contains_key(id)) {
176 continue;
177 }
178 if std::fs::metadata(&path).and_then(|m| m.modified()).is_ok_and(past_keep) {
179 let _ = std::fs::remove_file(path);
180 }
181 }
182 }
183}
184
185#[cfg(test)]
186thread_local! {
187 static AFTER_FIRST_LOOK: std::cell::Cell<Option<fn(&std::path::Path)>> = const { std::cell::Cell::new(None) };
189}
190
191fn hex(bytes: &[u8]) -> String {
192 bytes.iter().map(|b| format!("{b:02x}")).collect()
193}
194
195fn record_id(headers: &HeaderMap, key: &str) -> String {
197 let mut h = Sha256::new();
198 for name in ["authorization", "x-api-key"] {
199 h.update(headers.get(name).map(HeaderValue::as_bytes).unwrap_or_default());
200 h.update([0]);
201 }
202 h.update(key.as_bytes());
203 hex(&h.finalize())
204}
205
206fn refused(status: StatusCode, message: &str) -> Response {
207 let body = serde_json::json!({"error": {"message": message, "type": "invalid_request_error", "param": HEADER, "code": "idempotency_key"}});
208 (status, axum::Json(body)).into_response()
209}
210
211pub async fn layer(State(records): State<Arc<Records>>, req: Request, next: Next) -> Response {
212 if req.method() != axum::http::Method::POST {
213 return next.run(req).await;
214 }
215 let Some(key) = req.headers().get(HEADER).and_then(|v| v.to_str().ok()).map(str::to_string) else {
216 return next.run(req).await;
217 };
218 if key.is_empty() || key.len() > 255 {
219 return refused(StatusCode::BAD_REQUEST, "an Idempotency-Key is 1 to 255 characters");
220 }
221 let id = record_id(req.headers(), &key);
222 let (parts, body) = req.into_parts();
223 let Ok(bytes) = axum::body::to_bytes(body, MAX_BODY).await else {
224 return refused(StatusCode::PAYLOAD_TOO_LARGE, "a request this large is not taken with an Idempotency-Key");
225 };
226 let request: [u8; 32] = {
227 let mut h = Sha256::new();
228 h.update(parts.uri.path().as_bytes());
229 h.update([0]);
230 h.update(&bytes);
231 h.finalize().into()
232 };
233 let other = || refused(StatusCode::UNPROCESSABLE_ENTITY, "this Idempotency-Key was sent with another request; use a new key for it");
234
235 let live = { records.live.lock().expect("records").get(&id).filter(|r| !r.expired()).cloned() };
236 if let Some(rec) = live {
237 return if rec.request == request { replay(rec, true).await } else { other() };
238 }
239 if let Some((stored, recording)) = records.stored(&id) {
240 if stored != hex(&request) {
241 return other();
242 }
243 return replay(Record::new(request, recording), true).await;
244 }
245
246 let (rec, first) = {
248 let mut live = records.live.lock().expect("records");
249 live.retain(|_, r| !r.expired());
250 match live.get(&id) {
251 Some(rec) => (Arc::clone(rec), false),
252 None => {
253 let rec = Record::new(request, Recording::default());
254 live.insert(id.clone(), Arc::clone(&rec));
255 (rec, true)
256 }
257 }
258 };
259 if !first {
260 return if rec.request == request { replay(rec, true).await } else { other() };
261 }
262 records.sweep_when_due();
263 records.start(&id);
264 let req = Request::from_parts(parts, Body::from(bytes));
265 let (records2, rec2) = (Arc::clone(&records), Arc::clone(&rec));
266 tokio::spawn(async move {
268 let resp = next.run(req).await;
269 let (parts, body) = resp.into_parts();
270 {
271 let mut st = rec2.state.lock().expect("recording");
272 st.status = Some(parts.status.as_u16());
273 st.headers = parts
274 .headers
275 .iter()
276 .filter(|(n, _)| kept(n))
277 .filter_map(|(n, v)| Some((n.as_str().to_string(), v.to_str().ok()?.to_string())))
278 .collect();
279 records2.persist(&id, &rec2, &st);
280 }
281 rec2.changed.notify_waiters();
282 let mut stream = body.into_data_stream();
283 while let Some(chunk) = stream.next().await {
284 let Ok(chunk) = chunk else { break };
285 records2.append(&id, &chunk);
286 rec2.state.lock().expect("recording").body.extend_from_slice(&chunk);
287 rec2.changed.notify_waiters();
288 }
289 {
290 let mut st = rec2.state.lock().expect("recording");
291 st.done = true;
292 records2.persist(&id, &rec2, &st);
293 if records2.dir.is_some() {
297 records2.live.lock().expect("records").remove(&id);
298 } else {
299 *rec2.done_at.lock().expect("record") = Some(std::time::SystemTime::now());
300 }
301 }
302 rec2.changed.notify_waiters();
303 });
304 replay(rec, false).await
305}
306
307async fn replay(rec: Arc<Record>, again: bool) -> Response {
310 let (status, headers) = loop {
311 let changed = rec.changed.notified();
312 {
313 let st = rec.state.lock().expect("recording");
314 if let Some(status) = st.status {
315 break (status, st.headers.clone());
316 }
317 }
318 changed.await;
319 };
320 let body = futures_util::stream::unfold((rec, 0usize), |(rec, at)| async move {
321 loop {
322 let watch = Arc::clone(&rec);
323 let changed = watch.changed.notified();
324 let ready = {
325 let st = rec.state.lock().expect("recording");
326 if st.body.len() > at {
327 Some(Some((Bytes::copy_from_slice(&st.body[at..]), st.body.len())))
328 } else if st.done {
329 Some(None)
330 } else {
331 None
332 }
333 };
334 match ready {
335 Some(Some((chunk, next))) => return Some((Ok::<_, std::io::Error>(chunk), (rec, next))),
336 Some(None) => return None,
337 None => changed.await,
338 }
339 }
340 });
341 let mut resp = Response::new(Body::from_stream(body));
342 *resp.status_mut() = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
343 for (n, v) in headers {
344 if let (Ok(n), Ok(v)) = (HeaderName::try_from(n), HeaderValue::try_from(v)) {
345 resp.headers_mut().append(n, v);
346 }
347 }
348 if again {
349 resp.headers_mut().insert(REPLAYED, HeaderValue::from_static("true"));
350 }
351 resp
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use std::sync::atomic::{AtomicU64, Ordering};
358
359 fn server(records: Arc<Records>) -> (String, tokio::runtime::Runtime) {
361 let calls = Arc::new(AtomicU64::new(0));
362 let app = axum::Router::new()
363 .route(
364 "/x",
365 axum::routing::post(move || {
366 let n = calls.fetch_add(1, Ordering::Relaxed) + 1;
367 async move { n.to_string() }
368 }),
369 )
370 .layer(axum::middleware::from_fn_with_state(records, layer));
371 let rt = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
372 let listener = rt.block_on(tokio::net::TcpListener::bind("127.0.0.1:0")).unwrap();
373 let addr = listener.local_addr().unwrap().to_string();
374 rt.spawn(async move { axum::serve(listener, app).await });
375 (addr, rt)
376 }
377
378 fn post(addr: &str, key: &str) -> (bool, String) {
380 let agent: ureq::Agent = ureq::Agent::config_builder()
381 .timeout_global(Some(std::time::Duration::from_secs(20)))
382 .http_status_as_error(false)
383 .build()
384 .new_agent();
385 let mut resp = agent.post(&format!("http://{addr}/x")).header(HEADER, key).send("the request").unwrap();
386 (resp.headers().contains_key(REPLAYED), resp.body_mut().read_to_string().unwrap())
387 }
388
389 fn until(what: impl Fn() -> bool) -> bool {
391 let t = std::time::Instant::now();
392 while !what() {
393 if t.elapsed() > std::time::Duration::from_secs(20) {
394 return false;
395 }
396 std::thread::sleep(std::time::Duration::from_millis(5));
397 }
398 true
399 }
400
401 #[test]
402 fn an_answer_kept_in_memory_past_its_day_is_not_replayed() {
403 let records = Arc::new(Records::new(None));
404 let (addr, _rt) = server(Arc::clone(&records));
405 assert_eq!(post(&addr, "k"), (false, "1".into()));
406 let rec = records.live.lock().unwrap().get(&record_id(&HeaderMap::new(), "k")).cloned().unwrap();
407 assert!(until(|| rec.done_at.lock().unwrap().is_some()), "the answer is done");
408 assert_eq!(post(&addr, "k"), (true, "1".into()));
409 *rec.done_at.lock().unwrap() = Some(std::time::SystemTime::now() - KEEP - std::time::Duration::from_secs(3600));
411 assert_eq!(post(&addr, "k"), (false, "2".into()), "run again, not the answer kept a day ago");
412 }
413
414 #[test]
415 fn a_running_server_sweeps_what_it_kept_past_its_day_from_disk() {
416 let dir = std::env::temp_dir().join(format!("superfluid-idem-sweep-{}", std::process::id()));
417 std::fs::create_dir_all(&dir).unwrap();
418 let records = Arc::new(Records::new(Some(dir.clone())));
419 let (addr, _rt) = server(Arc::clone(&records));
420 let old = [dir.join("old.json"), dir.join("old.body")];
422 for f in &old {
423 std::fs::write(f, "{}").unwrap();
424 let then = std::time::SystemTime::now() - KEEP - std::time::Duration::from_secs(3600);
425 std::fs::File::options().write(true).open(f).unwrap().set_modified(then).unwrap();
426 }
427 post(&addr, "a");
429 assert!(old.iter().all(|f| f.exists()));
430 *records.swept.lock().unwrap() = None;
432 post(&addr, "b");
433 assert!(until(|| old.iter().all(|f| !f.exists())), "a record kept past its day is swept while the server runs");
434 assert_eq!(post(&addr, "a"), (true, "1".into()), "what is kept within its day stays");
435 let _ = std::fs::remove_dir_all(&dir);
436 }
437
438 #[test]
439 fn a_sweep_leaves_the_files_of_a_key_being_answered_again() {
440 let dir = std::env::temp_dir().join(format!("superfluid-idem-live-{}", std::process::id()));
441 std::fs::create_dir_all(&dir).unwrap();
442 let records = Records::new(None);
443 records.live.lock().unwrap().insert("again".into(), Record::new([0; 32], Recording::default()));
445 let files = [dir.join("again.json"), dir.join("again.body"), dir.join("gone.json"), dir.join("gone.body")];
446 for f in &files {
447 std::fs::write(f, "{}").unwrap();
448 let then = std::time::SystemTime::now() - KEEP - std::time::Duration::from_secs(3600);
449 std::fs::File::options().write(true).open(f).unwrap().set_modified(then).unwrap();
450 }
451 sweep(&dir, &records.live);
452 assert!(files[..2].iter().all(|f| f.exists()), "the key being answered again keeps its files");
453 assert!(files[2..].iter().all(|f| !f.exists()), "a key no one holds is swept");
454 let _ = std::fs::remove_dir_all(&dir);
455 }
456
457 #[test]
458 fn a_sweep_leaves_a_record_answered_again_since_it_first_looked() {
459 let dir = std::env::temp_dir().join(format!("superfluid-idem-again-{}", std::process::id()));
462 std::fs::create_dir_all(&dir).unwrap();
463 let records = Records::new(None);
464 let files = [dir.join("again.json"), dir.join("again.body")];
465 for f in &files {
466 std::fs::write(f, "{}").unwrap();
467 let then = std::time::SystemTime::now() - KEEP - std::time::Duration::from_secs(3600);
468 std::fs::File::options().write(true).open(f).unwrap().set_modified(then).unwrap();
469 }
470 fn answered_again(path: &std::path::Path) {
471 std::fs::write(path, "{\"answered\":\"again\"}").unwrap();
472 }
473 AFTER_FIRST_LOOK.with(|f| f.set(Some(answered_again)));
474 sweep(&dir, &records.live);
475 AFTER_FIRST_LOOK.with(|f| f.set(None));
476 assert!(files.iter().all(|f| f.exists()), "a record answered again since the sweep first looked is kept");
477 let _ = std::fs::remove_dir_all(&dir);
478 }
479}