StackTraces
Spring AI已验证

Spring AI OpenAI Connection reset 与 SocketTimeout

@debug_master32 天前更新阅读 6 min00

国内直连 api.openai.com 又慢又不稳,两个报错会先后冒出来。SocketTimeoutException: Read timed out 是读超时,调超时或走流式能解决;Connection reset by peer 是连接被中间设备悄悄 RST,加大超时没用,得换代理节点或加重试。最省心的是 base-url 指到稳定中转,再显式加大读写超时。

Issue

国内服务器用 Spring AI 调 OpenAI,经常在两个地方栽跟头:请求要么卡住抛读超时,要么中途断掉报连接重置。两者的现象都像「网络不好」,实际根因和修法完全不同。

最容易踩的时间点是:刚接入能跑通(短文本、快网络),一上国内生产环境或换成长生成,就间歇性报错。OpenAiApi 默认只传 api-key 和 base-url,其余全走默认,超时和代理都没显式配,问题就从这里冒出来。

Environment

  • 网络:国内服务器,直连或经本地 Clash / v2ray 透明代理访问 api.openai.com,RTT(往返时延)偏高。
  • 构建:Spring Boot 3.x + Spring AI(OpenAI 后端),JDK 17 / 21。
  • 版本参考:issue #5594 环境为 Spring Boot 3.5.10、spring-ai-openai 1.1.2、Java 21、模型 gpt-5。
  • 超时默认值:2.0.0-M5 起底层换成官方 openai-java SDK,spring.ai.openai.timeout 默认 60 秒;更早版本没有这个属性。

StackTrace

读超时,走 JDK HttpURLConnection 这条链路时抛,报错原文:

org.springframework.web.client.ResourceAccessException: I/O error on POST request for "https://api.openai.com/v1/chat/completions": Read timed out

完整堆栈,最内层是 JDK Socket 抛出的 SocketTimeoutException:

java.net.SocketTimeoutException: Read timed out
    at java.base/sun.nio.ch.NioSocketImpl.timedRead(NioSocketImpl.java:283)
    at java.base/sun.nio.ch.NioSocketImpl.implRead(NioSocketImpl.java:309)
    at java.base/sun.nio.ch.NioSocketImpl.read(NioSocketImpl.java:350)
    at java.base/java.net.Socket$SocketInputStream.read(Socket.java:1091)
    at org.springframework.http.client.SimpleStreamingClientHttpRequest.executeInternal(SimpleStreamingClientHttpRequest.java:137)
    at org.springframework.web.client.RestClient$DefaultRequestBodyUriSpec.exchangeInternal(RestClient.java:400)
    at com.example.demo.chat.OpenAiService.ask(OpenAiService.java:28)
    at com.example.demo.chat.ChatController.ask(ChatController.java:22)

连接被重置,走 Reactor Netty(WebClient)这条链路时抛,报错原文:

org.springframework.web.reactive.function.client.WebClientRequestException: Connection reset

完整堆栈,最内层是 Netty 拿到的 SocketException: Connection reset:

reactor.core.Exceptions$ReactiveException: java.net.SocketException: Connection reset
    at reactor.netty.channel.FluxReceive.onInboundError(FluxReceive.java:298)
    at reactor.netty.channel.ChannelOperations.onInboundError(ChannelOperations.java:443)
    at reactor.netty.http.client.HttpClientOperations.onInboundError(HttpClientOperations.java:588)
    at reactor.netty.channel.ChannelOperationsHandler.exceptionCaught(ChannelOperationsHandler.java:158)
Caused by: java.net.SocketException: Connection reset
    at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(SocketChannelImpl.java:394)
    at java.base/sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:426)

Windows 上 JDK 会把后一个报成「远程主机强迫关闭了一个现有的连接」,本质是同一个东西。用 curl 直连时它显示为 Recv failure: Connection reset by peer。

Reproduce

最小依赖(当前版本官方 artifact 名,老版本为 spring-ai-openai-spring-boot-starter):

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>

不配代理、不调超时,最容易触发:

spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      base-url: https://api.openai.com/v1

一个最简调用:

@GetMapping("/ask")
String ask(@RequestParam String q) {
    return chatClient.prompt().user(q).call().content();
}

这段代码本身不报错,报错取决于网络环境。要在本地确定性复现,先用 curl 打到网络层:

curl -v --max-time 15 https://api.openai.com/v1/models \
  -H "Authorization: Bearer $OPENAI_API_KEY" 2>&1 | head -80

看到 * Connection reset by peer 或 curl: (56) Recv failure,就是链路被 RST;看到正常 HTTP 返回(哪怕是 401),说明网络通、问题在超时配置。

Root Cause

SocketTimeoutException:读超时被掐

SocketTimeoutException 是 JDK HttpURLConnection(SimpleClientHttpRequestFactory)抛的,不是 Netty。看到它,基本能判断走的是 JDK 客户端。

这条链路上有个容易误会的地方:readTimeout 不是「整个请求的总时长」,而是「两次数据到达之间的最大间隔」。所以即使总时长没超,只要服务端中间沉默太久——比如推理模型先想一阵才吐字——读超时照样触发。

国内再加一层:代理 RTT 高,长文本生成又动不动几十秒,一次请求很容易顶穿默认的 60 秒超时。

Connection reset:链路被 RST

这个和超时无关,加大超时没用。连接能建立、数据也传得动,但中途被一个 RST 包掐断,客户端就读到 Connection reset。国内网络下常见三个来源:

  • TCP RST 注入:中间设备对流量发 RST 包强制断连,还伴生 DNS 污染把域名解析到错误 IP。
  • 本地代理抖动:Clash、v2ray 节点不稳,或中转站主动断连。
  • 上游主动关闭:空闲连接、超长连接被负载均衡或 CDN 回收。

判断的关键是看有没有 HTTP 状态码。真正的 429 会带 JSON body 和 Retry-After 头;Connection reset、读超时这类网络层失败,通常拿不到任何 HTTP 响应。

The Fix

第一步:curl 定位是哪一层

先别急着改代码,用上面的 curl 直连一次。见 reset 就换代理,见超时或挂起就调超时,两步别混着修。

第二步:配好代理或换中转

最省心的做法是 base-url 指到稳定中转(one-api、new-api,或自建 Cloudflare Worker):

spring:
  ai:
    openai:
      base-url: https://your-relay.example.com/v1
      api-key: ${OPENAI_API_KEY}

只想走本地透明代理(Clash 默认 7890 端口),用 SimpleClientHttpRequestFactory.setProxy:

SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setProxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 7890)));

或者用 JVM 参数,JDK 的 HttpURLConnection 会读取:

java -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=7890 -jar app.jar

第三步:显式调大超时

新版直接配属性:

spring:
  ai:
    openai:
      timeout: 120s
      max-retries: 3

老版本没有 timeout 属性,就用自定义 RestClient.Builder,这条路所有版本都通吃:

@Bean
OpenAiApi openAiApi(@Value("${spring.ai.openai.api-key}") String apiKey,
                    @Value("${spring.ai.openai.base-url}") String baseUrl) {
    RestClient.Builder restClientBuilder = RestClient.builder()
        .requestFactory(ClientHttpRequestFactoryBuilder.simple()
            .connectTimeout(Duration.ofSeconds(10))
            .readTimeout(Duration.ofSeconds(120))
            .build());
    return OpenAiApi.builder()
        .apiKey(apiKey)
        .baseUrl(baseUrl)
        .restClientBuilder(restClientBuilder)
        .build();
}

第四步:长生成走流式 + 重试

读超时和长生成是死对头。用 .stream() 代替 .call(),token 持续往外吐,两次数据之间不会沉默太久,读超时自然不容易触发。

Connection reset 大多是偶发抖动,配 max-retries 加退避就能扛住大部分。但别对超时无限重试,长生成重一遍更慢,用流式更实在。

References(参考来源)

最后更新于 2026-09-09

这篇帮到你了吗?

刚解决了一个棘手的报错?花两分钟记录下来,帮助下一个遇到同样问题的开发者。

贡献一条解法