Better usage errors (#1941)

<img width="771" height="279" alt="image"
src="https://github.com/user-attachments/assets/e56f967f-bcd7-49f7-8a94-3d88df68b65a"
/>
This commit is contained in:
pakrym-oai
2025-08-07 09:46:13 -07:00
committed by GitHub
parent bc28b87c7b
commit 62ed5907f9
3 changed files with 39 additions and 7 deletions

View File

@@ -40,6 +40,16 @@ use crate::protocol::TokenUsage;
use crate::util::backoff; use crate::util::backoff;
use std::sync::Arc; use std::sync::Arc;
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: Error,
}
#[derive(Debug, Deserialize)]
struct Error {
code: String,
}
#[derive(Clone)] #[derive(Clone)]
pub struct ModelClient { pub struct ModelClient {
config: Arc<Config>, config: Arc<Config>,
@@ -225,6 +235,14 @@ impl ModelClient {
} }
Ok(res) => { Ok(res) => {
let status = res.status(); let status = res.status();
// Pull out RetryAfter header if present.
let retry_after_secs = res
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok());
// The OpenAI Responses endpoint returns structured JSON bodies even for 4xx/5xx // The OpenAI Responses endpoint returns structured JSON bodies even for 4xx/5xx
// errors. When we bubble early with only the HTTP status the caller sees an opaque // errors. When we bubble early with only the HTTP status the caller sees an opaque
// "unexpected status 400 Bad Request" which makes debugging nearly impossible. // "unexpected status 400 Bad Request" which makes debugging nearly impossible.
@@ -238,17 +256,24 @@ impl ModelClient {
return Err(CodexErr::UnexpectedStatus(status, body)); return Err(CodexErr::UnexpectedStatus(status, body));
} }
if status == StatusCode::TOO_MANY_REQUESTS {
let body = res.json::<ErrorResponse>().await.ok();
if let Some(ErrorResponse {
error: Error { code, .. },
}) = body
{
if code == "usage_limit_reached" {
return Err(CodexErr::UsageLimitReached);
} else if code == "usage_not_included" {
return Err(CodexErr::UsageNotIncluded);
}
}
}
if attempt > max_retries { if attempt > max_retries {
return Err(CodexErr::RetryLimit(status)); return Err(CodexErr::RetryLimit(status));
} }
// Pull out RetryAfter header if present.
let retry_after_secs = res
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok());
let delay = retry_after_secs let delay = retry_after_secs
.map(|s| Duration::from_millis(s * 1_000)) .map(|s| Duration::from_millis(s * 1_000))
.unwrap_or_else(|| backoff(attempt)); .unwrap_or_else(|| backoff(attempt));

View File

@@ -1290,6 +1290,7 @@ async fn run_turn(
Ok(output) => return Ok(output), Ok(output) => return Ok(output),
Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted), Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted),
Err(CodexErr::EnvVar(var)) => return Err(CodexErr::EnvVar(var)), Err(CodexErr::EnvVar(var)) => return Err(CodexErr::EnvVar(var)),
Err(e @ (CodexErr::UsageLimitReached | CodexErr::UsageNotIncluded)) => return Err(e),
Err(e) => { Err(e) => {
// Use the configured provider-specific stream retry budget. // Use the configured provider-specific stream retry budget.
let max_retries = sess.client.get_provider().stream_max_retries(); let max_retries = sess.client.get_provider().stream_max_retries();

View File

@@ -62,6 +62,12 @@ pub enum CodexErr {
#[error("unexpected status {0}: {1}")] #[error("unexpected status {0}: {1}")]
UnexpectedStatus(StatusCode, String), UnexpectedStatus(StatusCode, String),
#[error("Usage limit has been reached")]
UsageLimitReached,
#[error("Usage not included with the plan")]
UsageNotIncluded,
/// Retry limit exceeded. /// Retry limit exceeded.
#[error("exceeded retry limit, last status: {0}")] #[error("exceeded retry limit, last status: {0}")]
RetryLimit(StatusCode), RetryLimit(StatusCode),