Preserve usage limit windows in turn and compaction analytics (#48174)

## Why

Usage limit errors discarded the server-selected window responsible for the limit, leaving turn and compaction analytics unable to distinguish five-hour limits from weekly limits.

## What changed

Preserve optional `limit_window_minutes` from usage limit error responses in `UsageLimitReachedError` and report it as `usage_limit_window_minutes` in turn and compaction events. Accept unsigned integers that fit in `u16`; treat missing, null, malformed, or out-of-range values as unknown. Events for other error kinds report `null`.

## Testing

Add coverage for HTTP and wrapped WebSocket error mapping, analytics serialization and reduction, and app-server propagation to turn and local/remote compaction events. Cases include five-hour and weekly windows, missing or invalid values, and unrelated errors.

GitOrigin-RevId: a32066a007559d91a3c868a756bfd0f976777176
This commit is contained in:
rhan-oai
2026-09-25 19:05:15 +00:00
committed by copyberry
parent f6a4bd81e3
commit 44a9bfa145
15 changed files with 371 additions and 0 deletions
+7
View File
@@ -204,6 +204,12 @@ fn map_api_error_details(err: ApiError) -> CodexErr {
return CodexErr::UsageLimitReached(UsageLimitReachedError {
plan_type: err.error.plan_type,
resets_at,
limit_window_minutes: err
.error
.limit_window_minutes
.as_ref()
.and_then(Value::as_u64)
.and_then(|minutes| u16::try_from(minutes).ok()),
rate_limits: rate_limits.map(Box::new),
promo_message,
rate_limit_reached_type,
@@ -340,4 +346,5 @@ struct UsageErrorBody {
error_type: Option<String>,
plan_type: Option<PlanType>,
resets_at: Option<i64>,
limit_window_minutes: Option<Value>,
}
@@ -457,6 +457,48 @@ fn map_api_error_distinguishes_http_quota_errors_from_rate_limits() {
}
}
#[test]
fn map_api_error_preserves_optional_usage_limit_window() {
let cases = [
(None, None),
(Some(serde_json::json!(null)), None),
(Some(serde_json::json!(300)), Some(300)),
(Some(serde_json::json!(10080)), Some(10080)),
(Some(serde_json::json!(-1)), None),
(Some(serde_json::json!(65536)), None),
(Some(serde_json::json!("300")), None),
(Some(serde_json::json!({"minutes": 300})), None),
];
for (window, expected) in cases {
let mut body = serde_json::json!({
"error": {
"type": "usage_limit_reached",
"plan_type": "pro",
}
});
if let Some(window) = window.as_ref() {
body["error"]["limit_window_minutes"] = window.clone();
}
let err = map_api_error(ApiError::Transport(TransportError::Http {
retry_after: None,
status: http::StatusCode::TOO_MANY_REQUESTS,
url: None,
headers: None,
body: Some(body.to_string()),
}));
let CodexErrorDetails::UsageLimitReached(usage_limit) = err.details() else {
panic!("expected usage-limit error for {window:?}, got {err:?}");
};
assert_eq!(usage_limit.limit_window_minutes, expected, "{window:?}");
assert_eq!(
err.to_codex_protocol_error(),
CodexErrorInfo::UsageLimitExceeded
);
}
}
#[test]
fn map_api_error_maps_usage_limit_limit_name_header() {
let mut headers = HeaderMap::new();
@@ -1057,6 +1057,33 @@ mod tests {
assert!(body.contains("The usage limit has been reached"));
}
#[test]
fn wrapped_websocket_usage_limit_preserves_optional_window() {
for (window, expected) in [(Some(10080), Some(10080)), (None, None)] {
let mut payload = json!({
"type": "error",
"status": 429,
"error": {
"type": "usage_limit_reached",
"plan_type": "pro"
}
});
if let Some(window) = window {
payload["error"]["limit_window_minutes"] = json!(window);
}
let payload = payload.to_string();
let wrapped = parse_wrapped_websocket_error_event(&payload).expect("websocket error");
let api_error = map_wrapped_websocket_error_event(wrapped, payload).expect("API error");
let err = crate::api_bridge::map_api_error(api_error);
let codex_protocol::error::CodexErrorDetails::UsageLimitReached(usage_limit) =
err.details()
else {
panic!("expected usage-limit error, got {err:?}");
};
assert_eq!(usage_limit.limit_window_minutes, expected);
}
}
#[test]
fn parse_wrapped_websocket_error_event_ignores_non_error_payloads() {
let payload = json!({