rust
64 lines · 8 steps
Idempotent payments with an Axum extractor
A custom extractor pulls the Idempotency-Key header and a shared cache replays prior responses so retries never double-charge.
Explained by
highlit
1use axum::{
2 async_trait,
3 extract::{FromRequestParts, State},
4 http::{request::Parts, StatusCode},
5 response::{IntoResponse, Response},
6 Json,
7};
8use serde_json::json;
9use std::sync::Arc;
10use tokio::sync::Mutex;
11use std::collections::HashMap;
12
13pub struct IdempotencyKey(pub String);
14
15#[async_trait]
16impl<S> FromRequestParts<S> for IdempotencyKey
17where
18 S: Send + Sync,
19{
20 type Rejection = Response;
21
22 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
23 let raw = parts
24 .headers
25 .get("Idempotency-Key")
26 .and_then(|v| v.to_str().ok())
27 .filter(|v| !v.trim().is_empty())
28 .ok_or_else(|| {
29 (
30 StatusCode::BAD_REQUEST,
31 Json(json!({ "error": "missing or empty Idempotency-Key header" })),
32 )
33 .into_response()
34 })?;
35
36 Ok(IdempotencyKey(raw.to_owned()))
37 }
38}
39
40#[derive(Clone, Default)]
41pub struct IdempotencyCache {
42 inner: Arc<Mutex<HashMap<String, (StatusCode, serde_json::Value)>>>,
43}
44
45pub async fn create_payment(
46 State(cache): State<IdempotencyCache>,
47 IdempotencyKey(key): IdempotencyKey,
48 Json(payload): Json<serde_json::Value>,
49) -> Response {
50 let mut store = cache.inner.lock().await;
51
52 if let Some((status, body)) = store.get(&key) {
53 return (*status, Json(body.clone())).into_response();
54 }
55
56 let charged = json!({
57 "id": uuid::Uuid::new_v4(),
58 "status": "succeeded",
59 "amount": payload["amount"].clone(),
60 });
61
62 store.insert(key, (StatusCode::CREATED, charged.clone()));
63 (StatusCode::CREATED, Json(charged)).into_response()
64}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1A custom FromRequestParts extractor lets you validate and reject a request before your handler ever runs.
- 2Keying responses by a client-supplied token turns unsafe retries into safe, replayable operations.
- 3Wrapping shared mutable state in Arc<Mutex<..>> lets many concurrent handlers share one cache safely.
Related explainers
java
public class SlidingLogRateLimiter { private final int maxRequests; private final long windowMillis;
How a sliding-log rate limiter works
rate-limiting
concurrency
sliding-window
Advanced
8 steps
go
package theme import ( "fmt"
Per-tenant HTML templates with Gin's renderer
multi-tenancy
concurrency
html-templates
Advanced
8 steps
rust
use std::cmp::Ordering; pub struct PrefixIndex { entries: Vec<String>,
Prefix search with binary partitioning in Rust
binary-search
sorting
case-insensitive
Intermediate
7 steps
python
import time import threading from enum import Enum from functools import wraps
Building a circuit breaker in Python
circuit-breaker
resilience
decorators
Advanced
7 steps
typescript
import { Injectable } from '@angular/core'; import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse } from '@angular/common/http'; import { BehaviorSubject, Observable, throwError } from 'rxjs'; import { catchError, filter, take, switchMap } from 'rxjs/operators';
Refreshing auth tokens in an Angular interceptor
http-interceptor
token-refresh
rxjs
Advanced
8 steps
rust
use std::cmp::Ordering; use std::str::FromStr; #[derive(Debug, Clone, PartialEq, Eq)]
Parsing and ordering semantic versions in Rust
parsing
trait-implementation
ordering
Intermediate
8 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/idempotent-payments-with-an-axum-extractor-explained-rust-39c2/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.