From 9a42348161e4f41a342824045c5e4b724e6a48f4 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Mon, 19 Oct 2026 16:47:58 -0500 Subject: [PATCH] fix: read a 429's limit type from the API's detail `Error::RateLimit`'s `limit_type` was read only from an `X-RateLimit-Type` header the Tango API does not send, so it was always `None`. It is now `"daily"` or `"burst"`, read from the rate named in the 429 body's `detail` (`Rate limit exceeded for daily. ...`), matching tango-python 1.18.2; a header still wins when present. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++ crates/tango/src/error.rs | 8 +-- crates/tango/src/transport.rs | 115 +++++++++++++++++++++++++++++++- crates/tango/tests/transport.rs | 22 ++++++ 4 files changed, 145 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 554af79..fbd4026 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ This project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### `makegov-tango` + +#### Fixed + +- **`Error::RateLimit`'s `limit_type` now says which limit a 429 hit.** It was read only from an `X-RateLimit-Type` header the Tango API does not send, so it was always `None`. It is now `"daily"` or `"burst"`, read from the rate the API names in the 429's `detail` (`Rate limit exceeded for daily. Please try again in 16 hours, 58 minutes.`), so a daily lockout that lasts until midnight UTC can be told apart from a burst limit that clears in seconds. Only the rate's name is read, never the wait, and a premium endpoint's daily budget reports as `"daily"`. An `X-RateLimit-Type` header still wins if the server sends one, and the value is `None` when the response names no limit. + ## [0.3.0] — 2026-10-19 Pre-1.0 (SemVer 0.x): the change under **Breaking** ships without a deprecation cycle. diff --git a/crates/tango/src/error.rs b/crates/tango/src/error.rs index 66f1a49..82ce046 100644 --- a/crates/tango/src/error.rs +++ b/crates/tango/src/error.rs @@ -82,15 +82,13 @@ pub enum Error { /// HTTP 429 — the caller exceeded a rate limit. /// - /// `retry_after` is populated from the `Retry-After` header when present - /// (in seconds, capped at 10s by the retry loop). `limit_type` is - /// populated from `X-RateLimit-Type` when the server sets it - /// (e.g. `"minute"`, `"hour"`, `"day"`). + /// `retry_after` is populated from the `Retry-After` header when present (in seconds, capped at 10s by the retry loop). + /// `limit_type` is `"daily"` or `"burst"`, read from the rate the API names in the body's `detail`; an `X-RateLimit-Type` header takes precedence when the server sets one, and it is `None` when neither names a limit. #[error("tango: rate limit exceeded (status 429); retry after {retry_after}s")] RateLimit { /// Seconds to wait before retrying, as suggested by the server. retry_after: u32, - /// The rate-limit bucket the server reported, when present. + /// Which limit was hit (`"daily"` or `"burst"`), when the response says. limit_type: Option, /// The parsed error body, when one was returned. response: Option, diff --git a/crates/tango/src/transport.rs b/crates/tango/src/transport.rs index 0e5e1d3..b42bca6 100644 --- a/crates/tango/src/transport.rs +++ b/crates/tango/src/transport.rs @@ -326,12 +326,18 @@ fn decode_error(status: StatusCode, headers: &HeaderMap, body: &[u8]) -> Error { }, 429 => { let retry_after = parse_retry_after(headers).as_secs(); + let detail = parsed_value + .as_ref() + .and_then(|v| v.get("detail")) + .and_then(Value::as_str); Error::RateLimit { retry_after: u32::try_from(retry_after).unwrap_or(u32::MAX), limit_type: headers .get(RATE_LIMIT_TYPE_HDR) .and_then(|v| v.to_str().ok()) - .map(str::to_string), + .filter(|s| !s.is_empty()) + .map(str::to_string) + .or_else(|| detail.and_then(limit_type_from_detail).map(str::to_string)), response, } } @@ -344,6 +350,37 @@ fn decode_error(status: StatusCode, headers: &HeaderMap, body: &[u8]) -> Error { } } +/// Which limit a 429 tripped, read from the API's detail: `"daily"`, `"burst"`, or `None` when the detail names neither. +/// +/// The API names the rate in `Rate limit exceeded for daily. Please try again in 16 hours, 58 minutes.`, so only the name after `exceeded for` is read: the wait can say "minutes" on a daily lockout. +fn limit_type_from_detail(detail: &str) -> Option<&'static str> { + const MARKER: &str = "exceeded for "; + let lower = detail.to_lowercase(); + let is_name_char = |c: char| c.is_alphanumeric() || c == '_' || c == '-'; + let name = lower.find(MARKER).and_then(|at| { + let rest = &lower[at + MARKER.len()..]; + let end = rest.find(|c: char| !is_name_char(c)).unwrap_or(rest.len()); + (end > 0).then(|| &rest[..end]) + }); + let text = name.unwrap_or(&lower); + if text.contains("daily") || contains_word(text, "day") { + Some("daily") + } else if text.contains("burst") || text.contains("minute") { + Some("burst") + } else { + None + } +} + +fn contains_word(text: &str, word: &str) -> bool { + let is_word_char = |c: char| c.is_alphanumeric() || c == '_'; + text.match_indices(word).any(|(at, _)| { + let before = text[..at].chars().next_back(); + let after = text[at + word.len()..].chars().next(); + !before.is_some_and(is_word_char) && !after.is_some_and(is_word_char) + }) +} + /// The server's own explanation from an error body, e.g. why a 403 refused the request. /// /// The API sends either `{"detail": "..."}` or a structured body such as a tier gate's `{"error": "...", "code": "tier_required", ...}`, sometimes nested under `detail`. @@ -451,6 +488,82 @@ mod tests { ); } + #[test] + fn limit_type_reads_the_rate_name_not_the_wait() { + let cases = [ + ( + "Rate limit exceeded for daily. Please try again in 16 hours, 58 minutes.", + Some("daily"), + ), + ( + "Rate limit exceeded for daily. Please try again in 1 minute.", + Some("daily"), + ), + ( + "Rate limit exceeded for burst. Please try again in 45 seconds.", + Some("burst"), + ), + ( + "Rate limit exceeded for burst. Please try again in 1 minute.", + Some("burst"), + ), + ( + "Rate limit exceeded for premium_daily. Please try again in 3 hours.", + Some("daily"), + ), + ( + "Rate limit exceeded for burst. Please try again in 1 day.", + Some("burst"), + ), + ("Too many requests.", None), + ("", None), + ]; + for (detail, expected) in cases { + assert_eq!(limit_type_from_detail(detail), expected, "{detail}"); + } + } + + fn rate_limit_type(headers: &HeaderMap, body: &[u8]) -> Option { + match decode_error(StatusCode::TOO_MANY_REQUESTS, headers, body) { + Error::RateLimit { limit_type, .. } => limit_type, + other => panic!("expected Error::RateLimit, got {other:?}"), + } + } + + #[test] + fn rate_limit_type_from_body_detail() { + let body = br#"{"detail": "Rate limit exceeded for daily. Please try again in 16 hours, 58 minutes.", "wait_in_seconds": 61080}"#; + assert_eq!( + rate_limit_type(&HeaderMap::new(), body).as_deref(), + Some("daily") + ); + } + + #[test] + fn rate_limit_type_none_without_a_recognisable_detail() { + let h = HeaderMap::new(); + assert_eq!( + rate_limit_type(&h, br#"{"detail": "Too many requests."}"#), + None + ); + assert_eq!( + rate_limit_type(&h, br#"{"detail": {"error": "daily"}}"#), + None + ); + assert_eq!(rate_limit_type(&h, b"daily burst"), None); + assert_eq!(rate_limit_type(&h, b""), None); + } + + #[test] + fn rate_limit_type_header_wins_over_detail() { + let body = br#"{"detail": "Rate limit exceeded for daily. Please try again in 2 hours."}"#; + let mut h = HeaderMap::new(); + h.insert(RATE_LIMIT_TYPE_HDR, "burst".parse().unwrap()); + assert_eq!(rate_limit_type(&h, body).as_deref(), Some("burst")); + h.insert(RATE_LIMIT_TYPE_HDR, "".parse().unwrap()); + assert_eq!(rate_limit_type(&h, body).as_deref(), Some("daily")); + } + fn api_error(status: u16, body: &[u8]) -> (u16, String, Option, String) { let err = decode_error( StatusCode::from_u16(status).unwrap(), diff --git a/crates/tango/tests/transport.rs b/crates/tango/tests/transport.rs index 49eb02c..5affc42 100644 --- a/crates/tango/tests/transport.rs +++ b/crates/tango/tests/transport.rs @@ -168,6 +168,28 @@ async fn maps_429_to_rate_limit_with_retry_after() { } } +#[tokio::test] +async fn rate_limit_type_read_from_a_daily_detail() { + let server = MockServer::start_async().await; + let _m = server + .mock_async(|when, then| { + when.method(GET).path("/api/version/"); + then.status(429).header("retry-after", "61080").json_body(serde_json::json!({ + "detail": "Rate limit exceeded for daily. Please try again in 16 hours, 58 minutes.", + "wait_in_seconds": 61080 + })); + }) + .await; + + let client = make_client(&server); + match client.get_version().await { + Err(Error::RateLimit { limit_type, .. }) => { + assert_eq!(limit_type.as_deref(), Some("daily")); + } + other => panic!("expected RateLimit, got {other:?}"), + } +} + #[tokio::test] async fn maps_500_to_api_error() { let server = MockServer::start_async().await;