mirror of
https://github.com/instructkr/claw-code.git
synced 2026-11-15 00:01:11 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a29e08883 | ||
|
|
7041894439 | ||
|
|
333298ae53 | ||
|
|
fe2b64ae9e |
No files matched your search
@@ -29,7 +29,7 @@ jobs:
|
||||
bin: claw
|
||||
artifact_name: claw-linux-x64
|
||||
- name: macos-arm64
|
||||
os: macos-14
|
||||
os: macos-15
|
||||
bin: claw
|
||||
artifact_name: claw-macos-arm64
|
||||
- name: windows-x64
|
||||
|
||||
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.13] - 2026-11-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Anthropic and OpenAI-compatible providers now retry HTTP 529 (overloaded) responses and transient gateway HTTP 400 responses (empty or unparseable body, connection reset, broken pipe, empty reply) with the existing bounded backoff, instead of failing the request immediately (#3381)
|
||||
|
||||
## [0.1.12] - 2026-11-08
|
||||
|
||||
### Fixed
|
||||
|
||||
Generated
+11
-11
@@ -93,7 +93,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "api"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"reqwest",
|
||||
@@ -478,7 +478,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "claw-analog"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"api",
|
||||
"clap",
|
||||
@@ -498,7 +498,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "claw-rag-service"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"blake3",
|
||||
@@ -531,7 +531,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "commands"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"plugins",
|
||||
"runtime",
|
||||
@@ -540,7 +540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "compat-harness"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"commands",
|
||||
"runtime",
|
||||
@@ -1568,7 +1568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mock-anthropic-service"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"api",
|
||||
"serde_json",
|
||||
@@ -1761,7 +1761,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "plugins"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2136,7 +2136,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "runtime"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"glob",
|
||||
@@ -2263,7 +2263,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "rusty-claude-cli"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"api",
|
||||
"commands",
|
||||
@@ -2624,7 +2624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "telemetry"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2904,7 +2904,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tools"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"api",
|
||||
"aspect-core",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
@@ -889,7 +889,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
let request_id = request_id_from_headers(&headers).map(String::into_boxed_str);
|
||||
let body = response.text().await.unwrap_or_else(|_| String::new());
|
||||
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
|
||||
let retryable = is_retryable_status(status);
|
||||
let retryable = is_retryable_status(status) || is_retryable_400(status, &body);
|
||||
let retry_after = parse_retry_after(&headers, status).map(Box::new);
|
||||
|
||||
Err(ApiError::Api {
|
||||
@@ -923,7 +923,10 @@ fn parse_retry_after(
|
||||
}
|
||||
|
||||
const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
|
||||
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
|
||||
matches!(
|
||||
status.as_u16(),
|
||||
408 | 409 | 429 | 500 | 502 | 503 | 504 | 529
|
||||
)
|
||||
}
|
||||
|
||||
/// Some providers return HTTP 400 with an unparseable body when a gateway
|
||||
@@ -1146,6 +1149,56 @@ mod tests {
|
||||
format!("http://{address}/oauth/token")
|
||||
}
|
||||
|
||||
fn spawn_status_server(status: u16, body: &'static str) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||
let address = listener.local_addr().expect("local addr");
|
||||
thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("accept connection");
|
||||
let mut buffer = [0_u8; 4096];
|
||||
let _ = stream.read(&mut buffer).expect("read request");
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} STATUS\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.expect("write response");
|
||||
});
|
||||
format!("http://{address}/v1/messages")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expect_success_marks_529_overloaded_retryable() {
|
||||
let url = spawn_status_server(
|
||||
529,
|
||||
r#"{"error":{"type":"overloaded_error","message":"Overloaded"}}"#,
|
||||
);
|
||||
let response = reqwest::get(&url).await.expect("response");
|
||||
let error = super::expect_success(response)
|
||||
.await
|
||||
.expect_err("529 should error");
|
||||
assert!(error.is_retryable(), "529 overloaded must be retryable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expect_success_marks_gateway_flake_400_retryable() {
|
||||
let url = spawn_status_server(400, "HTTP 400 from backend (no parseable body)");
|
||||
let response = reqwest::get(&url).await.expect("response");
|
||||
let error = super::expect_success(response)
|
||||
.await
|
||||
.expect_err("400 should error");
|
||||
assert!(
|
||||
error.is_retryable(),
|
||||
"gateway-flake 400 body must be retryable"
|
||||
);
|
||||
let url = spawn_status_server(400, "invalid request: max_tokens must be positive");
|
||||
let response = reqwest::get(&url).await.expect("response");
|
||||
let error = super::expect_success(response)
|
||||
.await
|
||||
.expect_err("400 should error");
|
||||
assert!(!error.is_retryable(), "a real 400 must not be retried");
|
||||
}
|
||||
#[test]
|
||||
fn read_api_key_requires_presence() {
|
||||
let _guard = env_lock();
|
||||
@@ -1445,6 +1498,40 @@ mod tests {
|
||||
assert!(!super::is_retryable_status(
|
||||
reqwest::StatusCode::UNAUTHORIZED
|
||||
));
|
||||
// Anthropic returns the non-standard 529 when the API is overloaded;
|
||||
// it is transient and must be retried like a 503.
|
||||
assert!(super::is_retryable_status(
|
||||
reqwest::StatusCode::from_u16(529).expect("529 is a valid status")
|
||||
));
|
||||
assert!(!super::is_retryable_status(
|
||||
reqwest::StatusCode::BAD_REQUEST
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_flake_400_bodies_are_retryable() {
|
||||
// Gateways and proxies surface transient network failures as HTTP 400
|
||||
// with an unparseable body; is_retryable_400 detects them so
|
||||
// expect_success marks the resulting error retryable.
|
||||
for body in [
|
||||
"HTTP 400 from backend (no parseable body)",
|
||||
"upstream connection reset by peer",
|
||||
"Broken pipe while reading response",
|
||||
"Empty reply from server",
|
||||
] {
|
||||
assert!(
|
||||
super::is_retryable_400(reqwest::StatusCode::BAD_REQUEST, body),
|
||||
"body should be retryable: {body}"
|
||||
);
|
||||
}
|
||||
assert!(!super::is_retryable_400(
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"invalid request: max_tokens must be positive"
|
||||
));
|
||||
assert!(!super::is_retryable_400(
|
||||
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"connection reset"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1763,7 +1763,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
let request_id = request_id_from_headers(&headers).map(String::into_boxed_str);
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
|
||||
let retryable = is_retryable_status(status);
|
||||
let retryable = is_retryable_status(status) || is_retryable_400(status, &body);
|
||||
let retry_after = parse_retry_after(&headers, status).map(Box::new);
|
||||
|
||||
let suggested_action = suggested_action_for_status(status);
|
||||
@@ -1799,7 +1799,10 @@ fn parse_retry_after(
|
||||
}
|
||||
|
||||
const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
|
||||
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
|
||||
matches!(
|
||||
status.as_u16(),
|
||||
408 | 409 | 429 | 500 | 502 | 503 | 504 | 529
|
||||
)
|
||||
}
|
||||
|
||||
/// Some providers return HTTP 400 with an unparseable body when a gateway
|
||||
@@ -1858,9 +1861,9 @@ impl StringExt for String {
|
||||
mod tests {
|
||||
use super::{
|
||||
build_chat_completion_request, chat_completions_endpoint, is_reasoning_model,
|
||||
model_requires_reasoning_content_in_history, normalize_finish_reason, normalize_response,
|
||||
openai_tool_choice, parse_tool_arguments, OpenAiCompatClient, OpenAiCompatConfig,
|
||||
StreamState,
|
||||
is_retryable_400, is_retryable_status, model_requires_reasoning_content_in_history,
|
||||
normalize_finish_reason, normalize_response, openai_tool_choice, parse_tool_arguments,
|
||||
OpenAiCompatClient, OpenAiCompatConfig, StreamState,
|
||||
};
|
||||
use crate::error::ApiError;
|
||||
use crate::test_env_lock as env_lock;
|
||||
@@ -1872,6 +1875,97 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
fn spawn_status_server(status: u16, body: &'static str) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||
let address = listener.local_addr().expect("local addr");
|
||||
thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("accept connection");
|
||||
let mut buffer = [0_u8; 4096];
|
||||
let _ = stream.read(&mut buffer).expect("read request");
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} STATUS\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.expect("write response");
|
||||
});
|
||||
format!("http://{address}/v1/messages")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expect_success_marks_529_overloaded_retryable() {
|
||||
let url = spawn_status_server(
|
||||
529,
|
||||
r#"{"error":{"type":"overloaded_error","message":"Overloaded"}}"#,
|
||||
);
|
||||
let response = reqwest::get(&url).await.expect("response");
|
||||
let error = super::expect_success(response)
|
||||
.await
|
||||
.expect_err("529 should error");
|
||||
assert!(error.is_retryable(), "529 overloaded must be retryable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expect_success_marks_gateway_flake_400_retryable() {
|
||||
let url = spawn_status_server(400, "HTTP 400 from backend (no parseable body)");
|
||||
let response = reqwest::get(&url).await.expect("response");
|
||||
let error = super::expect_success(response)
|
||||
.await
|
||||
.expect_err("400 should error");
|
||||
assert!(
|
||||
error.is_retryable(),
|
||||
"gateway-flake 400 body must be retryable"
|
||||
);
|
||||
let url = spawn_status_server(400, "invalid request: max_tokens must be positive");
|
||||
let response = reqwest::get(&url).await.expect("response");
|
||||
let error = super::expect_success(response)
|
||||
.await
|
||||
.expect_err("400 should error");
|
||||
assert!(!error.is_retryable(), "a real 400 must not be retried");
|
||||
}
|
||||
#[test]
|
||||
fn retryable_statuses_are_detected() {
|
||||
assert!(is_retryable_status(reqwest::StatusCode::TOO_MANY_REQUESTS));
|
||||
assert!(is_retryable_status(
|
||||
reqwest::StatusCode::INTERNAL_SERVER_ERROR
|
||||
));
|
||||
assert!(!is_retryable_status(reqwest::StatusCode::UNAUTHORIZED));
|
||||
// Several OpenAI-compatible gateways (and Anthropic) signal overload
|
||||
// with the non-standard 529; it is transient and must be retried.
|
||||
assert!(is_retryable_status(
|
||||
reqwest::StatusCode::from_u16(529).expect("529 is a valid status")
|
||||
));
|
||||
assert!(!is_retryable_status(reqwest::StatusCode::BAD_REQUEST));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_flake_400_bodies_are_retryable() {
|
||||
for body in [
|
||||
"HTTP 400 from backend (no parseable body)",
|
||||
"upstream connection reset by peer",
|
||||
"Broken pipe while reading response",
|
||||
"Empty reply from server",
|
||||
] {
|
||||
assert!(
|
||||
is_retryable_400(reqwest::StatusCode::BAD_REQUEST, body),
|
||||
"body should be retryable: {body}"
|
||||
);
|
||||
}
|
||||
assert!(!is_retryable_400(
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"invalid request: max_tokens must be positive"
|
||||
));
|
||||
assert!(!is_retryable_400(
|
||||
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"connection reset"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_translation_uses_openai_compatible_shape() {
|
||||
|
||||
@@ -82,7 +82,7 @@ async fn send_message_posts_json_and_parses_response() {
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("user-agent").map(String::as_str),
|
||||
Some("claude-code/0.1.12")
|
||||
Some("claude-code/0.1.13")
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("anthropic-beta").map(String::as_str),
|
||||
|
||||
Reference in new issue
Block a user