Skip to main content

vta_sdk/
rate_limit.rs

1//! Rate-limit attribution: which service refused a request, how long to wait,
2//! and which knob to turn.
3//!
4//! A `429` is not a server fault, and "the VTA rate-limited you" is not the only
5//! way to get one. A request from a VTA client can be refused by the VTA's own
6//! limiter, by a reverse proxy or load balancer in front of it, by the DIDComm
7//! mediator, or by a DID host while resolving a `did:webvh`. Each is tuned in a
8//! different place, and an operator who cannot tell them apart tunes the wrong
9//! one — or reads a `429` as the service being down.
10//!
11//! [`crate::error::VtaError::RateLimited`] carries the attribution as a
12//! [`RateLimitSource`]. This module owns everything that attribution needs:
13//! the header contract, `Retry-After` parsing, and — deliberately in one place,
14//! so a renamed config key is a one-line change — the names of the knobs the
15//! operator guidance points at.
16//!
17//! # The attribution contract
18//!
19//! A VTA marks every `429` its own limiter emits with
20//! [`SOURCE_HEADER`]`: vta` and a `Retry-After` in seconds. A `429` *without*
21//! that header, received from a VTA URL, is unattributable: a proxy or load
22//! balancer in front of the VTA, or a VTA older than the header. The client
23//! reports that honestly as [`RateLimitSource::Upstream`] rather than guessing.
24
25use chrono::{DateTime, Utc};
26
27/// Response header naming the service whose limiter refused the request.
28///
29/// Values: `vta`, `vtc`, `mediator`, `did-host`. Absent, or any other value,
30/// reads as [`RateLimitSource::Upstream`].
31pub const SOURCE_HEADER: &str = "x-rate-limit-source";
32
33/// Standard `Retry-After` (RFC 9110 §10.2.3): delta-seconds or an HTTP-date.
34pub const RETRY_AFTER_HEADER: &str = "retry-after";
35
36/// `tower-governor`'s own wait hint, in seconds. Sent by VTAs that predate
37/// [`SOURCE_HEADER`]; read only when `Retry-After` is absent.
38pub const LEGACY_RETRY_AFTER_HEADER: &str = "x-ratelimit-after";
39
40/// Which service's rate limiter refused the request.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42#[non_exhaustive]
43pub enum RateLimitSource {
44    /// The VTA's own limiter (labelled with [`SOURCE_HEADER`]`: vta`).
45    Vta,
46    /// A VTC's limiter (labelled `vtc`). Kept distinct from [`Self::Vta`]: the
47    /// VTC is a different service with a different audience and different
48    /// knobs, and the SDK's auth helpers are used against both.
49    Vtc,
50    /// The DIDComm / TSP mediator.
51    Mediator,
52    /// A DID host (e.g. `did-hosting-control`, or the host serving a
53    /// `did:webvh` log during resolution).
54    DidHost,
55    /// Unattributable: the `429` carried no recognised [`SOURCE_HEADER`]. A
56    /// reverse proxy / load balancer in front of the service, or a service old
57    /// enough not to label its limits.
58    Upstream,
59}
60
61impl RateLimitSource {
62    /// Read [`SOURCE_HEADER`]. Absent or unrecognised is
63    /// [`Self::Upstream`] — the one answer that does not claim to know more
64    /// than the response said.
65    #[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    /// Human label for the refusing party, used in error messages.
77    #[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/// Parse a `Retry-After` value into an absolute instant.
96///
97/// Accepts both RFC 9110 forms: delta-seconds (`"4"`) and an HTTP-date
98/// (`"Wed, 21 Oct 2015 07:28:00 GMT"`). Anything else is `None` — an
99/// unparseable hint is no hint, not an error.
100///
101/// Absolute rather than a `Duration` to match
102/// [`crate::error::VtaError::Unavailable`], so the retry owner treats both hints
103/// the same way; `now` is a parameter so the conversion is testable.
104#[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        // Clamp before converting: a hostile `u64::MAX` must not overflow.
109        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
117// ── Operator guidance ───────────────────────────────────────────────
118//
119// Every config key, CLI flag and doc path the rate-limit guidance names lives
120// here, once. `macro_rules!` rather than `const` because the SDK's
121// `suggested_fix` returns `&'static str` and so has to be built with
122// `concat!`, which only takes literals; the `pub const`s re-expose the same
123// literals for the CLI renderer, which substitutes the binary name.
124
125macro_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
162/// VTA `[server]` key: seconds per token for the auth / bootstrap limiter.
163pub const VTA_INTERVAL_KEY: &str = vta_interval_key!();
164/// VTA `[server]` key: burst for the auth / bootstrap limiter.
165pub const VTA_BURST_KEY: &str = vta_burst_key!();
166/// VTA `[server]` key: seconds per token for the limiter on the VTA's own
167/// `did.jsonl`.
168pub const VTA_DID_LOG_INTERVAL_KEY: &str = vta_did_log_interval_key!();
169/// VTA `[server]` key: burst for the limiter on the VTA's own `did.jsonl`.
170pub const VTA_DID_LOG_BURST_KEY: &str = vta_did_log_burst_key!();
171/// VTA `[server]` key that decides whether the limiter keys on
172/// `X-Forwarded-For` (behind a proxy) or on the TCP peer.
173pub const VTA_TRUST_XFF_KEY: &str = vta_trust_xff_key!();
174/// Operator guide for the VTA's limiters.
175pub const VTA_DOCS: &str = vta_docs!();
176/// `config update` flags that retune the VTA's auth / bootstrap limiter at
177/// runtime, without the binary name.
178pub const VTA_RUNTIME_FLAGS: &str =
179    "config update --rate-limit-interval-secs <N> --rate-limit-burst <N>";
180/// `config update` flags that retune the VTA's `did.jsonl` limiter at runtime.
181pub const VTA_DID_LOG_RUNTIME_FLAGS: &str =
182    "config update --did-log-rate-limit-interval-secs <N> --did-log-rate-limit-burst <N>";
183/// The mediator's limiter keys.
184pub const MEDIATOR_KEYS: &str = mediator_keys!();
185
186/// The static hint for a refusal from `source`. Backs
187/// [`crate::error::VtaError::suggested_fix`]; the CLI renders a richer,
188/// binary-aware version from the constants above.
189#[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        // Must not panic or overflow.
303        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}