rustlavel_http/
request_id.rs1use crate::handler::BoxFuture;
27use crate::middleware::{Middleware, Next};
28use crate::request::Request;
29use crate::response::Response;
30use std::hash::{BuildHasher, Hasher};
31use std::sync::atomic::{AtomicU64, Ordering};
32
33pub const HEADER: &str = "x-request-id";
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Assigned(pub String);
39
40tokio::task_local! {
41 static CURRENT: String;
42}
43
44pub fn current() -> Option<String> {
49 CURRENT.try_with(|id| id.clone()).ok()
50}
51
52#[derive(Debug, Clone)]
53pub struct RequestId {
54 header: String,
55 trust_incoming: bool,
56}
57
58impl Default for RequestId {
59 fn default() -> Self {
60 RequestId { header: HEADER.to_string(), trust_incoming: true }
61 }
62}
63
64impl RequestId {
65 pub fn new() -> Self {
66 Self::default()
67 }
68
69 pub fn header(mut self, name: &str) -> Self {
71 self.header = name.to_ascii_lowercase();
72 self
73 }
74
75 pub fn ignore_incoming(mut self) -> Self {
80 self.trust_incoming = false;
81 self
82 }
83
84 fn choose(&self, request: &Request) -> String {
85 if self.trust_incoming
86 && let Some(incoming) = request.header(&self.header)
87 && looks_like_an_id(incoming)
88 {
89 return incoming.to_string();
90 }
91 generate()
92 }
93}
94
95fn looks_like_an_id(candidate: &str) -> bool {
98 !candidate.is_empty()
99 && candidate.len() <= 128
100 && candidate.bytes().all(|b| b.is_ascii_graphic())
101}
102
103pub fn generate() -> String {
111 static STATE: AtomicU64 = AtomicU64::new(0);
112
113 let mut state = STATE.load(Ordering::Relaxed);
114 if state == 0 {
115 let mut hasher = std::hash::RandomState::new().build_hasher();
116 hasher.write_u128(
117 std::time::SystemTime::now()
118 .duration_since(std::time::UNIX_EPOCH)
119 .map_or(0, |d| d.as_nanos()),
120 );
121 hasher.write_u32(std::process::id());
122 let seed = hasher.finish() | 1;
125 state = match STATE.compare_exchange(0, seed, Ordering::Relaxed, Ordering::Relaxed) {
126 Ok(_) => seed,
127 Err(existing) => existing,
128 };
129 }
130
131 let next = || {
134 let counter = STATE.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed);
135 let mut z = counter.wrapping_add(state);
136 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
137 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
138 z ^ (z >> 31)
139 };
140 let (high, low) = (next(), next());
141
142 let high = (high & 0xFFFF_FFFF_FFFF_0FFF) | 0x0000_0000_0000_4000;
144 let low = (low & 0x3FFF_FFFF_FFFF_FFFF) | 0x8000_0000_0000_0000;
145 format!(
146 "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
147 high >> 32,
148 (high >> 16) & 0xFFFF,
149 high & 0xFFFF,
150 low >> 48,
151 low & 0xFFFF_FFFF_FFFF
152 )
153}
154
155impl Middleware for RequestId {
156 fn handle(&self, mut request: Request, next: Next) -> BoxFuture<Response> {
157 let id = self.choose(&request);
158 let header = self.header.clone();
159 request.extend(Assigned(id.clone()));
160
161 Box::pin(CURRENT.scope(id.clone(), async move {
162 let mut response = next.run(request).await;
163 response.headers.set(&header, id);
166 response
167 }))
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use crate::method::Method;
175 use crate::router::Router;
176 use crate::testing::TestClient;
177 use std::collections::HashSet;
178
179 fn client(middleware: RequestId) -> TestClient {
180 let mut router = Router::new();
181 router.middleware(middleware);
182 router.get("/", |req: Request| async move {
183 let from_request = req.request_id().unwrap_or("none").to_string();
184 let from_task = current().unwrap_or_else(|| "none".to_string());
185 Response::text(format!("{from_request}|{from_task}"))
186 });
187 TestClient::new(router)
188 }
189
190 #[test]
191 fn generated_ids_look_like_uuids_and_do_not_repeat() {
192 let ids: HashSet<String> = (0..10_000).map(|_| generate()).collect();
193 assert_eq!(ids.len(), 10_000, "ten thousand ids, ten thousand distinct values");
194
195 for id in ids.iter().take(50) {
196 assert_eq!(id.len(), 36, "{id}");
197 let parts: Vec<&str> = id.split('-').collect();
198 assert_eq!(parts.iter().map(|p| p.len()).collect::<Vec<_>>(), [8, 4, 4, 4, 12], "{id}");
199 assert!(parts[2].starts_with('4'), "version nibble: {id}");
200 assert!(matches!(parts[3].as_bytes()[0], b'8' | b'9' | b'a' | b'b'), "variant: {id}");
201 }
202 }
203
204 #[tokio::test]
205 async fn a_request_without_an_id_is_given_one_everywhere() {
206 let response = client(RequestId::new()).get("/").await;
207 let id = response.header(HEADER).expect("the response carries the id").to_string();
208 assert_eq!(id.len(), 36);
209 assert_eq!(response.body(), format!("{id}|{id}"), "request, task-local and header all agree");
210 }
211
212 #[tokio::test]
213 async fn an_incoming_id_is_kept_by_default() {
214 let request = Request::new(Method::Get, "/").with_header(HEADER, "edge-7f3a");
215 let response = client(RequestId::new()).send(request).await;
216 assert_eq!(response.header(HEADER), Some("edge-7f3a"));
217 assert_eq!(response.body(), "edge-7f3a|edge-7f3a");
218 }
219
220 #[tokio::test]
221 async fn an_incoming_id_can_be_ignored() {
222 let request = Request::new(Method::Get, "/").with_header(HEADER, "edge-7f3a");
223 let response = client(RequestId::new().ignore_incoming()).send(request).await;
224 assert_ne!(response.header(HEADER), Some("edge-7f3a"));
225 assert_eq!(response.header(HEADER).unwrap().len(), 36);
226 }
227
228 #[tokio::test]
229 async fn garbage_in_the_header_is_replaced_not_forwarded() {
230 for bad in ["", "has space", "tab\there", "x".repeat(129).as_str(), "ünïcödé"] {
231 let request = Request::new(Method::Get, "/").with_header(HEADER, bad);
232 let response = client(RequestId::new()).send(request).await;
233 let id = response.header(HEADER).unwrap();
234 assert_ne!(id, bad);
235 assert_eq!(id.len(), 36, "replaced with a generated one");
236 }
237 }
238
239 #[tokio::test]
240 async fn the_header_name_is_configurable() {
241 let request = Request::new(Method::Get, "/").with_header("x-correlation-id", "corr-1");
242 let response = client(RequestId::new().header("X-Correlation-Id")).send(request).await;
243 assert_eq!(response.header("x-correlation-id"), Some("corr-1"));
244 assert_eq!(response.header(HEADER), None);
245 }
246
247 #[tokio::test]
248 async fn outside_a_request_there_is_no_current_id() {
249 assert_eq!(current(), None);
250 }
251
252 #[tokio::test]
253 async fn the_event_for_the_request_carries_the_id() {
254 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
257 let sink = seen.clone();
258 rustlavel_core::events::subscribe(move |event: &rustlavel_core::Event| {
259 if event.kind == "http.request" {
260 sink.lock().unwrap().push(event.field("request_id").and_then(|v| v.as_str().map(str::to_string)));
261 }
262 });
263
264 let request = Request::new(Method::Get, "/").with_header(HEADER, "traced-1");
265 client(RequestId::new()).send(request).await;
266
267 let seen = seen.lock().unwrap();
268 assert!(seen.iter().any(|id| id.as_deref() == Some("traced-1")), "{seen:?}");
269 }
270}