Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 3 additions & 5 deletions crates/tango/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// The parsed error body, when one was returned.
response: Option<ErrorBody>,
Expand Down
115 changes: 114 additions & 1 deletion crates/tango/src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand All @@ -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`.
Expand Down Expand Up @@ -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<String> {
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"<html>daily burst</html>"), 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<ErrorBody>, String) {
let err = decode_error(
StatusCode::from_u16(status).unwrap(),
Expand Down
22 changes: 22 additions & 0 deletions crates/tango/tests/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading