From 84cb44244626987cc4ea34a39145b809b37b58b2 Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 26 Apr 2026 08:34:11 +0800 Subject: [PATCH] fix(anthropic): log anthropic-ratelimit-* headers on 429 --- .../AgentClaudeCodeChatModelBuilder.java | 11 ++- .../RateLimitDiagnosticExchangeFilter.java | 66 +++++++++++++++ .../RateLimitDiagnosticInterceptor.java | 80 +++++++++++++++++++ 3 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticExchangeFilter.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticInterceptor.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java index 3f041907..d8a5eca4 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java @@ -135,14 +135,21 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder { .defaultHeader(HttpHeaders.USER_AGENT, userAgent) .defaultHeader(HttpHeaders.ACCEPT, "application/json") .defaultHeader("anthropic-dangerous-direct-browser-access", "true") - .defaultHeader("x-app", xApp); + .defaultHeader("x-app", xApp) + // Diagnostic: log Anthropic's rate-limit headers on 429 so we + // can tell apart "5h Pro quota exhausted" (tokens-remaining=0, + // retry-after huge) from "anti-abuse gate" (tokens-remaining + // large, retry-after small) from "burst limit hit" without + // staring at SDK internals. + .requestInterceptor(new RateLimitDiagnosticInterceptor()); WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder) .defaultHeader(HttpHeaders.AUTHORIZATION, authHeader) .defaultHeader(HttpHeaders.USER_AGENT, userAgent) .defaultHeader(HttpHeaders.ACCEPT, "application/json") .defaultHeader("anthropic-dangerous-direct-browser-access", "true") - .defaultHeader("x-app", xApp); + .defaultHeader("x-app", xApp) + .filter(new RateLimitDiagnosticExchangeFilter()); // NoopApiKey.getValue() returns "" → Spring AI's addDefaultHeadersIfMissing // skips x-api-key. The Builder.build() Assert.notNull on apiKey still diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticExchangeFilter.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticExchangeFilter.java new file mode 100644 index 00000000..1dc7d8b9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticExchangeFilter.java @@ -0,0 +1,66 @@ +package vip.mate.agent.chatmodel; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import reactor.core.publisher.Mono; + +import java.util.List; + +/** + * RFC-062 diagnostic helper for the streaming (WebClient) path. Mirrors + * {@link RateLimitDiagnosticInterceptor} for non-streaming RestClient calls — + * see that class's javadoc for how to interpret the logged headers. + * + *

Stream calls are where most chat traffic flows, so without this filter + * we wouldn't see rate-limit metadata for the most common 429 path. + */ +@Slf4j +class RateLimitDiagnosticExchangeFilter implements ExchangeFilterFunction { + + /** Anthropic's documented rate-limit response headers. */ + private static final List RATE_LIMIT_HEADERS = List.of( + "anthropic-ratelimit-requests-limit", + "anthropic-ratelimit-requests-remaining", + "anthropic-ratelimit-requests-reset", + "anthropic-ratelimit-tokens-limit", + "anthropic-ratelimit-tokens-remaining", + "anthropic-ratelimit-tokens-reset", + "anthropic-ratelimit-input-tokens-limit", + "anthropic-ratelimit-input-tokens-remaining", + "anthropic-ratelimit-input-tokens-reset", + "anthropic-ratelimit-output-tokens-limit", + "anthropic-ratelimit-output-tokens-remaining", + "anthropic-ratelimit-output-tokens-reset", + "retry-after"); + + @Override + public Mono filter(ClientRequest request, ExchangeFunction next) { + return next.exchange(request).doOnNext(response -> { + if (response.statusCode().value() == 429) { + logHeaders(response.headers().asHttpHeaders()); + } + }); + } + + private static void logHeaders(HttpHeaders headers) { + StringBuilder sb = new StringBuilder("[Anthropic 429] rate-limit headers: "); + boolean any = false; + for (String name : RATE_LIMIT_HEADERS) { + String value = headers.getFirst(name); + if (value != null) { + if (any) sb.append(", "); + sb.append(name).append('=').append(value); + any = true; + } + } + if (!any) { + log.warn("[Anthropic 429] no rate-limit headers present — likely anti-abuse gate, not real quota"); + } else { + log.warn(sb.toString()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticInterceptor.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticInterceptor.java new file mode 100644 index 00000000..8fd3b54c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/RateLimitDiagnosticInterceptor.java @@ -0,0 +1,80 @@ +package vip.mate.agent.chatmodel; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.IOException; +import java.util.List; + +/** + * RFC-062 diagnostic helper: when Anthropic returns 429 we want to see the + * {@code anthropic-ratelimit-*} headers in the log so we can tell apart the + * three distinct "rate limit" failure modes that all share the same + * {@code {"type":"rate_limit_error","message":"Error"}} body: + * + * + * + * + * + * + * + *
How to read the headers
Failure modetokens-remainingretry-after
5h Pro/Max quota exhausted0thousands of seconds
Anti-abuse fingerprint gatelargetens of seconds
Per-minute burst limitlargesingle-digit seconds
+ * + *

Spring AI logs the body but not the headers, so we'd be debugging blind + * without this. Sync (RestClient) variant; the WebFlux equivalent lives in + * {@link RateLimitDiagnosticExchangeFilter}. + */ +@Slf4j +class RateLimitDiagnosticInterceptor implements ClientHttpRequestInterceptor { + + /** Anthropic's documented rate-limit response headers. */ + private static final List RATE_LIMIT_HEADERS = List.of( + "anthropic-ratelimit-requests-limit", + "anthropic-ratelimit-requests-remaining", + "anthropic-ratelimit-requests-reset", + "anthropic-ratelimit-tokens-limit", + "anthropic-ratelimit-tokens-remaining", + "anthropic-ratelimit-tokens-reset", + "anthropic-ratelimit-input-tokens-limit", + "anthropic-ratelimit-input-tokens-remaining", + "anthropic-ratelimit-input-tokens-reset", + "anthropic-ratelimit-output-tokens-limit", + "anthropic-ratelimit-output-tokens-remaining", + "anthropic-ratelimit-output-tokens-reset", + "retry-after"); + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, + ClientHttpRequestExecution execution) throws IOException { + ClientHttpResponse response = execution.execute(request, body); + if (response.getStatusCode().value() == 429) { + logHeaders(response.getHeaders()); + } + return response; + } + + private static void logHeaders(HttpHeaders headers) { + StringBuilder sb = new StringBuilder("[Anthropic 429] rate-limit headers: "); + boolean any = false; + for (String name : RATE_LIMIT_HEADERS) { + String value = headers.getFirst(name); + if (value != null) { + if (any) sb.append(", "); + sb.append(name).append('=').append(value); + any = true; + } + } + if (!any) { + // Anthropic still returned 429 but didn't include any rate-limit + // headers — strong signal of the anti-abuse path (it generally + // doesn't bother filling them in). + log.warn("[Anthropic 429] no rate-limit headers present — likely anti-abuse gate, not real quota"); + } else { + log.warn(sb.toString()); + } + } +}