Title: ZioHttpInterpreter logs interrupt-only causes at ERROR level unconditionally
Bug
When a request-handling fiber is interrupted (e.g., due to client disconnect, idle timeout, or graceful shutdown), the foldCauseZIO in ZioHttpInterpreter.handleRequest logs the cause at ERROR level unconditionally:
interpreter.apply(serverRequest).foldCauseZIO(
cause => ZIO.logErrorCause(cause) *> ZIO.fail(Response.internalServerError(cause.squash.getMessage)),
...
)
These interrupts are expected connection lifecycle events — not application errors — and produce noisy, non-actionable ERROR logs in production.
How to reproduce
- Start a tapir server using
ZioHttpInterpreter with an endpoint that takes time to respond (e.g., queries a database or external cache)
- Send a request and disconnect the client before the server responds:
curl --max-time 1 http://localhost:8080/slow-endpoint
- Observe ERROR-level log with interrupt cause
Observed log output
zio.http.Response: Response(InternalServerError,Headers(),StringBody(Interrupted by fibers: #79761443))
at sttp.tapir.server.ziohttp.ZioHttpInterpreter.toHttp.handleRequest(ZioHttpInterpreter.scala:48)
at zio.http.Routes.Tree.forRoute(Routes.scala:424)
at zio.http.HandlerAspect.applyHandler(HandlerAspect.scala:142)
at zio.http.HandlerAspect.applyHandler(HandlerAspect.scala:145)
Context
The interrupt originates from zio-http's NettyRuntime, which adds a close listener to the Netty channel:
val close = closeListener(fiber)
ctx.channel().closeFuture.addListener(close)
When the channel closes (client disconnect, idle timeout, or graceful shutdown), the handler fiber is interrupted via fiber.interruptAsFork. This is expected behavior — the request can no longer be served, so the fiber is cancelled.
The interrupt-only cause bypasses tapir's ServerLogAndExceptionInterceptor (which only catches NonFatal exceptions) and reaches the outer foldCauseZIO, which logs at ERROR unconditionally.
Suggested fix
For interrupt-only causes (or more specifically disconnect-driven interrupts if detectable), avoid logging at ERROR and avoid treating them as server errors:
interpreter.apply(serverRequest).foldCauseZIO(
cause =>
if (cause.isInterruptedOnly)
ZIO.logDebugCause("Request interrupted", cause) *>
ZIO.fail(Response.internalServerError("Request interrupted"))
else
ZIO.logErrorCause(cause) *>
ZIO.fail(Response.internalServerError(cause.squash.getMessage)),
...
)
This is analogous to the fix in #3815 for the Netty server variant, which distinguished expected disconnects from real errors.
Environment
- tapir: 1.13.19
- zio-http: 3.3.1
- zio: 2.1.26
Title:
ZioHttpInterpreterlogs interrupt-only causes at ERROR level unconditionallyBug
When a request-handling fiber is interrupted (e.g., due to client disconnect, idle timeout, or graceful shutdown), the
foldCauseZIOinZioHttpInterpreter.handleRequestlogs the cause at ERROR level unconditionally:These interrupts are expected connection lifecycle events — not application errors — and produce noisy, non-actionable ERROR logs in production.
How to reproduce
ZioHttpInterpreterwith an endpoint that takes time to respond (e.g., queries a database or external cache)Observed log output
Context
The interrupt originates from zio-http's
NettyRuntime, which adds a close listener to the Netty channel:When the channel closes (client disconnect, idle timeout, or graceful shutdown), the handler fiber is interrupted via
fiber.interruptAsFork. This is expected behavior — the request can no longer be served, so the fiber is cancelled.The interrupt-only cause bypasses tapir's
ServerLogAndExceptionInterceptor(which only catchesNonFatalexceptions) and reaches the outerfoldCauseZIO, which logs at ERROR unconditionally.Suggested fix
For interrupt-only causes (or more specifically disconnect-driven interrupts if detectable), avoid logging at ERROR and avoid treating them as server errors:
This is analogous to the fix in #3815 for the Netty server variant, which distinguished expected disconnects from real errors.
Environment