fix(anthropic): log anthropic-ratelimit-* headers on 429

This commit is contained in:
matevip 2026-04-26 08:34:11 +08:00
parent aabf2b8c32
commit 84cb442446
3 changed files with 155 additions and 2 deletions

View File

@ -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

View File

@ -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.
*
* <p>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<String> 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<ClientResponse> 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());
}
}
}

View File

@ -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:
*
* <table>
* <caption>How to read the headers</caption>
* <tr><th>Failure mode</th><th>tokens-remaining</th><th>retry-after</th></tr>
* <tr><td>5h Pro/Max quota exhausted</td><td>0</td><td>thousands of seconds</td></tr>
* <tr><td>Anti-abuse fingerprint gate</td><td>large</td><td>tens of seconds</td></tr>
* <tr><td>Per-minute burst limit</td><td>large</td><td>single-digit seconds</td></tr>
* </table>
*
* <p>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<String> 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());
}
}
}