fix(providers): don't retry deterministically-permanent 400s (thinking-block immutability) (#10005)

Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
This commit is contained in:
Kyle E DeFreitas 2026-06-30 17:15:41 -04:00 committed by GitHub
parent d056c72a2c
commit c45f757c9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -81,11 +81,27 @@ impl RetryConfig {
}
}
/// Substrings marking a `RequestFailed` (4xx) as deterministically permanent:
/// Anthropic rejects signed `thinking`/`redacted_thinking` blocks as immutable
/// once a thinking model's config changes mid-conversation, and the identical
/// payload is rebuilt on every retry — so retrying can never succeed.
const PERMANENT_REQUEST_FAILURE_MARKERS: &[&str] = &[
"blocks in the latest assistant message cannot be modified",
"must remain as they were in the original response",
];
fn is_permanent_request_failure(message: &str) -> bool {
PERMANENT_REQUEST_FAILURE_MARKERS
.iter()
.any(|marker| message.contains(marker))
}
pub fn should_retry(error: &ProviderError, config: &RetryConfig) -> bool {
match error {
ProviderError::RateLimitExceeded { .. }
| ProviderError::ServerError(_)
| ProviderError::NetworkError(_) => true,
ProviderError::RequestFailed(message) if is_permanent_request_failure(message) => false,
ProviderError::RequestFailed(_) => !config.transient_only,
_ => false,
}
@ -254,6 +270,33 @@ mod tests {
assert!(should_retry(&error, &config));
}
#[test]
fn never_retries_permanent_thinking_block_400() {
let config = RetryConfig::default();
let error = ProviderError::RequestFailed(
"Bad request (400): {\"message\":\"messages.3.content.1: `thinking` or \
`redacted_thinking` blocks in the latest assistant message cannot be \
modified. These blocks must remain as they were in the original \
response.\"}"
.into(),
);
assert!(!should_retry(&error, &config));
}
#[test]
fn permanent_request_failure_marker_detection() {
assert!(is_permanent_request_failure(
"messages.3.content.1: `thinking` blocks in the latest assistant message \
cannot be modified"
));
assert!(is_permanent_request_failure(
"These blocks must remain as they were in the original response."
));
assert!(!is_permanent_request_failure(
"Bad request (400): model not found"
));
}
#[test]
fn transient_only_skips_request_failed() {
let config = RetryConfig::default().transient_only();