1use chrono::{DateTime, Utc};
26
27pub const SOURCE_HEADER: &str = "x-rate-limit-source";
32
33pub const RETRY_AFTER_HEADER: &str = "retry-after";
35
36pub const LEGACY_RETRY_AFTER_HEADER: &str = "x-ratelimit-after";
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42#[non_exhaustive]
43pub enum RateLimitSource {
44 Vta,
46 Vtc,
50 Mediator,
52 DidHost,
55 Upstream,
59}
60
61impl RateLimitSource {
62 #[must_use]
66 pub fn from_source_header(value: Option<&str>) -> Self {
67 match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
68 Some("vta") => Self::Vta,
69 Some("vtc") => Self::Vtc,
70 Some("mediator") => Self::Mediator,
71 Some("did-host") => Self::DidHost,
72 _ => Self::Upstream,
73 }
74 }
75
76 #[must_use]
78 pub fn label(self) -> &'static str {
79 match self {
80 Self::Vta => "the VTA",
81 Self::Vtc => "the VTC",
82 Self::Mediator => "the mediator",
83 Self::DidHost => "the DID host",
84 Self::Upstream => "an unidentified service (proxy, load balancer, or older VTA)",
85 }
86 }
87}
88
89impl std::fmt::Display for RateLimitSource {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 f.write_str(self.label())
92 }
93}
94
95#[must_use]
105pub fn parse_retry_after(value: &str, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
106 let value = value.trim();
107 if let Ok(secs) = value.parse::<u64>() {
108 let secs = i64::try_from(secs).unwrap_or(i64::MAX).min(86_400 * 365);
110 return now.checked_add_signed(chrono::Duration::seconds(secs));
111 }
112 DateTime::parse_from_rfc2822(value)
113 .ok()
114 .map(|t| t.with_timezone(&Utc))
115}
116
117macro_rules! vta_interval_key {
126 () => {
127 "rate_limit_interval_secs"
128 };
129}
130macro_rules! vta_burst_key {
131 () => {
132 "rate_limit_burst"
133 };
134}
135macro_rules! vta_did_log_interval_key {
136 () => {
137 "did_log_rate_limit_interval_secs"
138 };
139}
140macro_rules! vta_did_log_burst_key {
141 () => {
142 "did_log_rate_limit_burst"
143 };
144}
145macro_rules! vta_trust_xff_key {
146 () => {
147 "trust_xff"
148 };
149}
150macro_rules! vta_docs {
151 () => {
152 "docs/02-vta/rate-limiting.md"
153 };
154}
155macro_rules! mediator_keys {
156 () => {
157 "`[limits] rate_limit_per_ip` / `rate_limit_burst` (per client IP), \
158 `did_rate_limit_per_second` / `did_rate_limit_burst` (per DID)"
159 };
160}
161
162pub const VTA_INTERVAL_KEY: &str = vta_interval_key!();
164pub const VTA_BURST_KEY: &str = vta_burst_key!();
166pub const VTA_DID_LOG_INTERVAL_KEY: &str = vta_did_log_interval_key!();
169pub const VTA_DID_LOG_BURST_KEY: &str = vta_did_log_burst_key!();
171pub const VTA_TRUST_XFF_KEY: &str = vta_trust_xff_key!();
174pub const VTA_DOCS: &str = vta_docs!();
176pub const VTA_RUNTIME_FLAGS: &str =
179 "config update --rate-limit-interval-secs <N> --rate-limit-burst <N>";
180pub const VTA_DID_LOG_RUNTIME_FLAGS: &str =
182 "config update --did-log-rate-limit-interval-secs <N> --did-log-rate-limit-burst <N>";
183pub const MEDIATOR_KEYS: &str = mediator_keys!();
185
186#[must_use]
190pub fn suggested_fix(source: RateLimitSource) -> &'static str {
191 match source {
192 RateLimitSource::Vta => concat!(
193 "The VTA's own rate limiter refused this request — the VTA is not down. Wait \
194 for the retry-after period and try again. To loosen it, raise `[server] ",
195 vta_burst_key!(),
196 "` or lower `",
197 vta_interval_key!(),
198 "` (seconds per token: lower is looser) for the auth / bootstrap endpoints, or `",
199 vta_did_log_interval_key!(),
200 "` / `",
201 vta_did_log_burst_key!(),
202 "` for the VTA's own did.jsonl; at runtime use `config update`. Behind a reverse \
203 proxy with `",
204 vta_trust_xff_key!(),
205 " = false` every client shares one bucket. See ",
206 vta_docs!(),
207 "."
208 ),
209 RateLimitSource::Vtc => {
210 "The VTC's rate limiter refused this request — the VTC is not down. Wait for the \
211 retry-after period and try again. The VTC's unauthenticated-route limiter is not \
212 configurable; behind a proxy, check the VTC's trust_xff setting so clients do not \
213 share one bucket."
214 }
215 RateLimitSource::Mediator => concat!(
216 "The DIDComm/TSP mediator rate-limited this request — neither it nor the VTA is \
217 down. Wait and retry. The mediator operator tunes ",
218 mediator_keys!(),
219 "; those are requests per second, so higher is looser."
220 ),
221 RateLimitSource::DidHost => {
222 "A DID host rate-limited this request (e.g. while resolving a did:webvh, or \
223 did-hosting-control's per-IP challenge limit). It is not tunable from the VTA: wait \
224 and retry, or ask the host's operator."
225 }
226 RateLimitSource::Upstream => concat!(
227 "A 429 arrived without an `x-rate-limit-source` header, so the SDK cannot say who \
228 sent it: a reverse proxy or load balancer in front of the service, or a VTA older \
229 than the header. Check the proxy / load balancer's limits and logs, or upgrade the \
230 VTA so its own refusals are labelled. See ",
231 vta_docs!(),
232 "."
233 ),
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 fn at(s: &str) -> DateTime<Utc> {
242 DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
243 }
244
245 #[test]
246 fn source_header_is_read_case_insensitively_and_absent_is_upstream() {
247 assert_eq!(
248 RateLimitSource::from_source_header(Some("vta")),
249 RateLimitSource::Vta
250 );
251 assert_eq!(
252 RateLimitSource::from_source_header(Some(" VTA ")),
253 RateLimitSource::Vta
254 );
255 assert_eq!(
256 RateLimitSource::from_source_header(Some("vtc")),
257 RateLimitSource::Vtc
258 );
259 assert_eq!(
260 RateLimitSource::from_source_header(Some("mediator")),
261 RateLimitSource::Mediator
262 );
263 assert_eq!(
264 RateLimitSource::from_source_header(Some("did-host")),
265 RateLimitSource::DidHost
266 );
267 assert_eq!(
268 RateLimitSource::from_source_header(None),
269 RateLimitSource::Upstream
270 );
271 assert_eq!(
272 RateLimitSource::from_source_header(Some("nginx")),
273 RateLimitSource::Upstream,
274 "an unknown label must not be promoted to a service we can name"
275 );
276 }
277
278 #[test]
279 fn retry_after_delta_seconds() {
280 let now = at("2026-09-16T12:00:00Z");
281 assert_eq!(
282 parse_retry_after("4", now),
283 Some(at("2026-09-16T12:00:04Z"))
284 );
285 assert_eq!(parse_retry_after(" 0 ", now), Some(now));
286 }
287
288 #[test]
289 fn retry_after_http_date() {
290 let now = at("2026-09-16T12:00:00Z");
291 assert_eq!(
292 parse_retry_after("Wed, 16 Sep 2026 12:00:30 GMT", now),
293 Some(at("2026-09-16T12:00:30Z"))
294 );
295 }
296
297 #[test]
298 fn retry_after_garbage_and_hostile_values() {
299 let now = at("2026-09-16T12:00:00Z");
300 assert_eq!(parse_retry_after("soon", now), None);
301 assert_eq!(parse_retry_after("-3", now), None);
302 assert!(parse_retry_after(&u64::MAX.to_string(), now).is_some());
304 }
305
306 #[test]
307 fn every_source_has_a_hint_naming_where_to_look() {
308 let vta = suggested_fix(RateLimitSource::Vta);
309 for needle in [
310 VTA_INTERVAL_KEY,
311 VTA_BURST_KEY,
312 VTA_DID_LOG_INTERVAL_KEY,
313 VTA_DID_LOG_BURST_KEY,
314 VTA_TRUST_XFF_KEY,
315 VTA_DOCS,
316 "lower is looser",
317 ] {
318 assert!(
319 vta.contains(needle),
320 "VTA hint must mention {needle}: {vta}"
321 );
322 }
323 assert!(suggested_fix(RateLimitSource::Mediator).contains(MEDIATOR_KEYS));
324 assert!(suggested_fix(RateLimitSource::Upstream).contains(SOURCE_HEADER));
325 assert!(suggested_fix(RateLimitSource::DidHost).contains("not tunable"));
326 assert!(suggested_fix(RateLimitSource::Vtc).contains("VTC"));
327 }
328}