429 不是「重试几次就好」这么简单。同一句 429 背后有
rate_limit、insufficient_quota等不同error.code,前者才该重试,后者重试没用。而且 Spring AI 默认对 4xx 不重试,直接抛NonTransientAiException,很多人看到「重试没生效」其实是配置问题。
Issue & Env(案发现场与环境)
- 业务场景:并发或高频调 OpenAI,请求弹 429,被 Spring AI 包成异常抛出,没按预期重试。
- 环境:Spring AI 1.x / 2.0,OpenAI 官方 API。重试相关配置在
spring.ai.retry前缀下,默认on-client-errors=false。
StackTrace(堆栈日志)
底层 HTTP 层抛的是 TooManyRequests,对应的 OpenAI 错误响应原文长这样:
{
"error": {
"message": "Rate limit reached for requests",
"type": "rate_limit_error",
"param": null,
"code": "rate_limit_exceeded"
}
}
Spring AI 默认不重试 4xx,把它包成不可重试异常往外抛,报错原文:
org.springframework.ai.retry.NonTransientAiException: 429 - {
"error": {
"message": "Rate limit reached for requests",
"type": "rate_limit_error"
}
}
完整堆栈,从 Spring AI 的重试判断一路回到业务调用:
org.springframework.ai.retry.NonTransientAiException: 429 - {
"error": {
"message": "Rate limit reached for requests",
"type": "rate_limit_error"
}
}
at org.springframework.ai.retry.RetryUtils.handleResponseError(RetryUtils.java:163)
at org.springframework.ai.chat.model.ChatModel.call(ChatModel.java:204)
at org.springframework.ai.chat.client.DefaultChatClient$DefaultChatClientRequestSpec$InternalChatClientRequest.call(DefaultChatClient.java:686)
at com.example.demo.chat.OpenAiService.summarize(OpenAiService.java:36)
at com.example.demo.chat.ChatController.summarize(ChatController.java:23)
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:205)
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:117)
Caused by: org.springframework.web.client.HttpClientErrorException$TooManyRequests: 429 Too Many Requests
at org.springframework.web.client.DefaultResponseErrorHandler.handleError(DefaultResponseErrorHandler.java:123)
at org.springframework.web.client.RestClient$DefaultResponseSpec$DefaultBodySpec.evaluate(RestClient.java:1187)
Reproduce(复现代码)
最小依赖:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
配好 key 后,用一个循环把短时间内的请求量打上去:
for (int i = 0; i < 100; i++) {
chatClient.prompt().user("hi").call().content();
}
一旦撞上账户的 RPM(每分钟请求数)或 TPM(每分钟 token 数)限制,就抛 429。报错是否重试,取决于 spring.ai.retry 的配置。
Root Cause(根因分析)
两件事容易混,得拆开看。
第一件,429 背后的原因不止一种。OpenAI 返回 429 时,error.code 可能是 rate_limit(临时限流),也可能是 credit_balance_exhausted、organization_spend_limit_exceeded 这类额度、余额、限额耗尽。前者等一会儿再试能过,后者重试多少次都不会恢复访问——官方文档明确说,billing 或 quota 类错误重试无用,得去充值或调整 limit。
第二件,Spring AI 的默认重试策略。spring.ai.retry.on-client-errors 默认 false,意思是 4xx 客户端错误一律不重试,直接抛 NonTransientAiException。429 属于 4xx,所以默认情况下你根本看不到重试。想让 429 走重试,要么开 on-client-errors=true,要么精准一点,把 429 加进 spring.ai.retry.on-http-codes。
还有一个坑:有些上游(尤其 Azure)的 429 会带一个超大 Retry-After 头,比如 24 小时。客户端要是盲从,会把线程挂一整天(issue #2078)。所以重试最好带退避上限,别无条件尊重 Retry-After。
The Fix(解决方案)
第一步:看 error.code 分流
拿到 429 先看 error.code,别急着加重试。
rate_limit:临时限流,降频或重试。credit_balance_exhausted、organization_spend_limit_exceeded等:额度类,去充值或调 limit,重试没用。
第二步:临时限流才配重试
只对 429 开重试,其他 4xx 保持不重试:
spring:
ai:
retry:
max-attempts: 5
backoff:
initial-interval: 1s
multiplier: 2
max-interval: 10s
on-http-codes:
- 429
on-http-codes 只放你确定要重试的状态码;exclude-on-http-codes 放你明确不想重试的码。两者都别开太宽,避免把额度类 429 也卷进重试。
第三步:控并发、拆 key
重试只是兜底,根子上得降并发、控制频率,或给不同服务分配独立的 key,别多个服务共用一个 key 顶同一个限额。
