WebMVC: upstream aborting the response body is logged by Tomcat as an unhandled ERROR and dispatched to `/error`

オープン
#4,357 コメント 2 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 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 の本文から書いたものです。

説明

waiting-for-triage

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 every HandlerFilterFunction has returned, so a route filter's try/catch around next.handle(request) never sees the exception.
  • Replacing MvcUtils.CLIENT_RESPONSE_INPUT_STREAM_ATTR from an outer filter doesn't work either, because the request is rebuilt with a copied attributes map.
  • Spring Framework's DisconnectedClientHelper handles 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

  1. 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?
  2. 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?
  3. For the /error dispatch 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

環境構築

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

spring-cloud/spring-cloud-gateway のほかの issue

spring-cloud/spring-cloud-gateway の issue をすべて見る

似ている issue

Java の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。