WebMVC: upstream aborting the response body is logged by Tomcat as an unhandled ERROR and dispatched to `/error`
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 初心者へのやさしさ
- 25/100
- issue の種類
- バグ
- 明瞭さ
- おおむね明確
- 活発さ
- 活発
- 技術スタック
- java, spring-boot
- 領域
- backend
調査の方向性
Start in AbstractProxyExchange.copyResponseBodyWithFlushing and copyResponseBody, where the upstream read failure is rethrown, and RestClientProxyExchange.doExchange, where the copy is invoked. Distinguish an upstream read failure from a client write failure, then decide how to abort the downstream response without propagating. The reproducer is a raw-socket test that sends a 200 text/event-stream, one event, then a TCP RST; done when the truncated stream still reaches the client and neither the Tomcat ERROR nor the /error dispatch is logged.
索引モデルが issue の本文から書いたものです。
説明
Describe the bug
Spring Cloud 2025.1.4 / spring-cloud-gateway-server-webmvc 5.0.4 (Spring Boot 4.0.9, Tomcat 11.0.27). The same behaviour occurs on 5.1.0-RC1 and on 4.1.x.
We run a WebMVC gateway in front of SSE (LLM streaming) backends. When a backend hits its own read timeout, it gives up on a stream it has already started: the status, headers and some events are already sent, and then it closes the connection without the terminating chunk. That's an everyday condition for us and nothing the gateway can fix. Even so, every occurrence shows up as an unhandled servlet exception:
ERROR o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
java.io.IOException: closed
at o.s.c.g.server.mvc.common.AbstractProxyExchange.copyResponseBodyWithFlushing(AbstractProxyExchange.java:97)
at o.s.c.g.server.mvc.common.AbstractProxyExchange.copyResponseBody(AbstractProxyExchange.java:50)
at o.s.c.g.server.mvc.handler.RestClientProxyExchange.lambda$doExchange$3(RestClientProxyExchange.java:82)
at o.s.c.g.server.mvc.handler.GatewayServerResponseBuilder$WriteFunctionResponse.writeToInternal(GatewayServerResponseBuilder.java:232)
at o.s.c.g.server.mvc.handler.AbstractGatewayServerResponse.writeTo(AbstractGatewayServerResponse.java:97)
at o.s.web.servlet.function.support.HandlerFunctionAdapter.handle(HandlerFunctionAdapter.java:110)
at o.s.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:963)
...
Caused by: java.io.IOException: chunked transfer encoding, state: READING_LENGTH
Caused by: java.net.SocketException: Connection reset
DEBUG o.s.web.servlet.DispatcherServlet : "INCLUDE" dispatch for POST "/error", parameters={}
WARN .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotWritableException: No converter for [class java.util.LinkedHashMap] with preset Content-Type 'text/event-stream']
What the client sees is already correct: a truncated chunked stream with no terminating chunk, so it can tell the stream didn't complete. The problem is the server side. An expected upstream condition produces one ERROR with a full stack trace and two more lines from an /error dispatch that can't write anything to a committed response. In our case this is the main source of noise in our error alerting.
As far as we can tell, there's no clean place to handle this in an application:
- The upstream body is read inside
ServerResponse.writeTo, after everyHandlerFilterFunctionhas returned, so a route filter'stry/catcharoundnext.handle(request)never sees the exception. - Replacing
MvcUtils.CLIENT_RESPONSE_INPUT_STREAM_ATTRfrom an outer filter doesn't work either, because the request is rebuilt with a copied attributes map. - Spring Framework's
DisconnectedClientHelperhandles the opposite case, where the downstream client went away. It matches on messages such as "connection reset", so it would also misclassify this upstream reset as a client disconnect.
Sample
A self-contained reproducer (single test, mvn test, JDK 21+): https://gist.github.com/Shawyeok/8bb7604a03b63598d0946a6a05dcd9f2
The test's upstream is a raw socket that sends 200 text/event-stream, one event, and then a TCP RST. The test asserts that the downstream stream is truncated, which passes, and that neither the Tomcat ERROR nor the /error dispatch appears, which fails.
What we do today (workaround)
We wrap the route's ServerResponse in a delegating GatewayServerResponse. Its writeTo hands SCG an HttpServletResponseWrapper that records whether a write or flush to the client failed:
- If the client write failed, we rethrow unchanged, as today.
- Otherwise the upstream read failed. We log one INFO line, increment a metric, and rethrow as
org.apache.coyote.CloseNowException. Tomcat logs that only at DEBUG and still closes the connection without the terminating chunk.
We also exclude ErrorMvcAutoConfiguration, since a gateway has no error page of its own to serve.
This works, but it depends on a Tomcat-internal exception type and on wrapping every route, so we'd rather not keep it.
Questions for the maintainers
- Is it intended that an upstream I/O failure during the body copy propagates to the servlet container as an unhandled exception? Or would you consider handling it inside the gateway, for example by telling an upstream read failure apart from a client write failure in
copyResponseBody, logging the former at a lower level, and aborting the downstream response without rethrowing? - If you'd rather keep it propagating, could it become an exception type or hook that applications can recognise and handle? A dedicated exception, a callback, or a property would all work for us. Is there an approach you'd recommend instead of ours?
- For the
/errordispatch on an already-committed proxied response: is that something the gateway should avoid, or is it Spring Boot / Tomcat behaviour that we should raise there?
Related: #3999 / #4217 changed how the upstream response is closed when the copy fails because the client disconnected. The upstream-side failure described here still rethrows on 5.0.4 and 5.1.0-RC1.
Happy to contribute a PR if you can point us at the direction you'd prefer.
- 主要言語
- Java
- スター
- 4.9k
- フォーク
- 3.5k
- 平均マージ
- 2日 4時間
- マージ済み PR(30日)
- 9
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートなし
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
spring-cloud/spring-cloud-gateway のほかの issue
-
RequestRateLimiterGatewayFilterFactory commits 429 response instead of throwing対応中かも @seonwooj0810 が 155 日前に担当しました。 オープンwaiting-for-triage
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
spring-cloud/spring-cloud-gateway#4175 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
Add @Order annotation to gatewayRestClientCustomizer Bean対応中かも @CheHyeonYeong が 227 日前に担当しました。 オープンenhancement help wanted
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
spring-cloud/spring-cloud-gateway#3626 · コメント 3 件 ·
メンテナーはふだん 1 日以内に返信
-
Ambiguity in section 19 of the documentation - Spring Cloud Gateway - Custom Route Predicate対応中かも @won-seoop が 154 日前に担当しました。 オープンdocumentation
難易度 2/5 1〜3時間 初心者へのやさしさ 62/100
spring-cloud/spring-cloud-gateway#3463 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
Improve TokenRelay filter documentation when Gateway acts as a BFF対応中かも このイシューにリンクされたプルリクエストがオープン中、またはマージ済みです。 オープンwaiting-for-triage
難易度 2/5 半日 初心者へのやさしさ 58/100
spring-cloud/spring-cloud-gateway#4355 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
waiting-for-triage
難易度 3/5 1〜2日 初心者へのやさしさ 35/100
spring-cloud/spring-cloud-gateway#4341 ·
メンテナーはふだん 1 日以内に返信
spring-cloud/spring-cloud-gateway の issue をすべて見る
似ている issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
langchain4j/langchain4j#7068 ·
メンテナーはふだん 1 日以内に返信
-
Mend: dependency security vulnerability untriaged
難易度 2/5 1〜3時間 初心者へのやさしさ 73/100
opensearch-project/k-NN#3693 ·
メンテナーはふだん 1 日以内に返信
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 67/100
メンテナーはふだん 1 日以内に返信
-
[Bug]: Encrypted PDF portfolios show an empty portfolio panel対応中かも @christophmertins が今日担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
Stirling-Tools/Stirling-PDF#8864 · リアクション 1 件 ·
メンテナーはふだん 2 日以内に返信
-
難易度 1/5 1時間未満 初心者へのやさしさ 85/100
CodingDocs/awesome-java#41 · コメント 1 件 ·