Skip to content

ZioHttpInterpreter logs interrupt-only causes at ERROR level unconditionally #5241

@asheeshdwivedi

Description

@asheeshdwivedi

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

  1. Start a tapir server using ZioHttpInterpreter with an endpoint that takes time to respond (e.g., queries a database or external cache)
  2. Send a request and disconnect the client before the server responds:
    curl --max-time 1 http://localhost:8080/slow-endpoint
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions