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
@@ -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();