Skip to content
This repository was archived by the owner on Jul 16, 2026. It is now read-only.

Commit 0835959

Browse files
committed
fix: treat HTTP 402 (payment required) as non-retryable
OpenRouter 402 'requires more credits' errors were being retried by both the SSE stream retry loop and the auto-poke continuation logic, wasting time and re-hitting the same deterministic failure. - openrouter_sse_stream: parse the reported HTTP status and short-circuit 4xx client errors (400/401/402/403/404/405/406/422) as non-retryable, before the loose 5xx substring heuristics. - auto-poke: add 402/credit-exhaustion markers to the non-retryable set. - add unit tests for both paths.
1 parent fb72261 commit 0835959

3 files changed

Lines changed: 99 additions & 0 deletions

File tree

crates/jcode-base/src/provider/openrouter_sse_stream.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,36 @@ async fn stream_response(
219219
Ok(())
220220
}
221221

222+
/// Extract the HTTP status code reported in a formatted provider error string.
223+
///
224+
/// Error strings produced in this module embed the status as `status: <code>`
225+
/// (e.g. `status: 402 Payment Required`). The input may be lowercased before
226+
/// it reaches here, so matching is case-insensitive.
227+
fn parsed_http_status(error_str: &str) -> Option<u16> {
228+
let lower = error_str.to_ascii_lowercase();
229+
let idx = lower.find("status:")?;
230+
let rest = lower[idx + "status:".len()..].trim_start();
231+
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
232+
if digits.len() == 3 {
233+
digits.parse().ok()
234+
} else {
235+
None
236+
}
237+
}
238+
222239
fn is_retryable_error(error_str: &str) -> bool {
240+
// Explicit non-retryable HTTP statuses take precedence over the loose
241+
// substring heuristics below. These are deterministic client-side failures
242+
// (auth, billing, malformed request) where retrying is futile and just
243+
// burns time/credits. 429 (rate limit) is intentionally NOT listed here so
244+
// it can still be retried.
245+
if let Some(status) = parsed_http_status(error_str) {
246+
match status {
247+
400 | 401 | 402 | 403 | 404 | 405 | 406 | 422 => return false,
248+
_ => {}
249+
}
250+
}
251+
223252
crate::provider::is_transient_transport_error(error_str)
224253
|| error_str.contains("stream error")
225254
|| error_str.contains("eof")
@@ -801,4 +830,49 @@ mod tests {
801830
assert!(hint.contains("Local Server"));
802831
assert!(hint.contains("/v1/models"));
803832
}
833+
834+
#[test]
835+
fn parsed_http_status_extracts_code() {
836+
assert_eq!(
837+
parsed_http_status("status: 402 payment required"),
838+
Some(402)
839+
);
840+
assert_eq!(parsed_http_status(" status:404 not found"), Some(404));
841+
assert_eq!(parsed_http_status("no status here"), None);
842+
// Embedded numbers elsewhere must not be misread as a status.
843+
assert_eq!(parsed_http_status("you requested 65536 tokens"), None);
844+
}
845+
846+
#[test]
847+
fn payment_required_is_not_retryable() {
848+
let err = "openai-compatible chat request failed\n endpoint: \
849+
https://openrouter.ai/api/v1/chat/completions\n model: openai/gpt-5.4\n \
850+
auth: openrouter_api_key\n status: 402 payment required\n response: \
851+
{\"error\":{\"message\":\"this request requires more credits, or fewer \
852+
max_tokens. you requested up to 65536 tokens, but can only afford 34424\"}}";
853+
assert!(!is_retryable_error(err));
854+
}
855+
856+
#[test]
857+
fn client_errors_are_not_retryable() {
858+
for status in [400u16, 401, 402, 403, 404, 405, 406, 422] {
859+
let err = format!("chat request failed\n status: {status} client error");
860+
assert!(
861+
!is_retryable_error(&err),
862+
"status {status} should not be retryable"
863+
);
864+
}
865+
}
866+
867+
#[test]
868+
fn server_errors_remain_retryable() {
869+
assert!(is_retryable_error(
870+
"chat request failed\n status: 503 service unavailable"
871+
));
872+
assert!(is_retryable_error(
873+
"chat request failed\n status: 500 internal server error"
874+
));
875+
// Rate limiting should still be retried.
876+
assert!(is_retryable_error("overloaded"));
877+
}
804878
}

crates/jcode-tui/src/tui/app/commands.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,14 @@ pub(super) fn is_non_retryable_auto_poke_error(error: &str) -> bool {
145145
"401 unauthorized",
146146
"403 forbidden",
147147
"insufficient_quota",
148+
"402 payment required",
149+
"payment required",
150+
"requires more credits",
151+
"add more credits",
152+
"more credits",
148153
"billing",
149154
"credit balance",
155+
"out of credits",
150156
];
151157

152158
deterministic_markers

crates/jcode-tui/src/tui/app/commands_tests.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,22 @@ fn parse_manual_subagent_spec_rejects_missing_prompt() {
5656
.expect_err("missing prompt should be rejected");
5757
assert!(err.contains("Missing prompt"));
5858
}
59+
60+
#[test]
61+
fn openrouter_402_payment_required_is_non_retryable() {
62+
use super::is_non_retryable_auto_poke_error;
63+
let err = "OpenAI-compatible chat request failed\n endpoint: \
64+
https://openrouter.ai/api/v1/chat/completions\n model: openai/gpt-5.4\n \
65+
auth: OPENROUTER_API_KEY\n status: 402 Payment Required\n response: \
66+
{\"error\":{\"message\":\"This request requires more credits, or fewer max_tokens. \
67+
You requested up to 65536 tokens, but can only afford 34424. To increase, visit \
68+
https://openrouter.ai/settings/credits and add more credits\",\"code\":402}}";
69+
assert!(is_non_retryable_auto_poke_error(err));
70+
}
71+
72+
#[test]
73+
fn transient_server_error_remains_retryable_for_auto_poke() {
74+
use super::is_non_retryable_auto_poke_error;
75+
let err = "OpenAI-compatible chat request failed\n status: 503 Service Unavailable";
76+
assert!(!is_non_retryable_auto_poke_error(err));
77+
}

0 commit comments

Comments
 (0)