Skip to content

A (possible) inconsistency between server and client interpretation #4736

@pierangeloc

Description

@pierangeloc

Not sure this is an issue, or I'm misusing the out/errorOut split.

We have a server that can return, with http status code 200, both a successful response, or an error response.

This example has been inspired by OCPI protocol that foresees a 200 http status response for every well formed call, the status code and possible error being in the response message.

  1. A 200 success call
curl --request GET \
  --url http://localhost:8080/test/ok

Response:
  
HTTP/1.1 200 Ok
content-type: application/json
date: Mon, 04 Aug 2025 09:31:29 GMT
content-length: 37
connection: close

{
  "data": "Success!",
  "statusCode": 1000
}
  1. A 500 error call
curl --request GET \
  --url http://localhost:8080/test/clientError

Response: 

HTTP/1.1 500 Internal Server Error
content-type: application/json
content-length: 41
connection: close

{
  "error": "Client error",
  "statusCode": 500
}
  1. A 200 Error call
curl --request GET \
  --url http://localhost:8080/test/anyOtherError

Response:

HTTP/1.1 200 Ok
content-type: application/json
date: Mon, 04 Aug 2025 09:32:59 GMT
content-length: 58
connection: close

{
  "error": "This is a 200 error response",
  "statusCode": 2000
}

We model this api with Tapir, and we try to invoke it through the sttp-interpreted client. I manually defined the circe codecs for each output type.

import sttp.client4.httpclient.zio.HttpClientZioBackend
import sttp.client4.{Backend, UriContext}
import sttp.model.StatusCode
import sttp.tapir.server.ziohttp.ZioHttpInterpreter
import zio._
import zio.http.{Response, Routes}

object TestTapirServer extends ZIOAppDefault {

  import sttp.tapir._
  import sttp.tapir.generic.auto._

  sealed trait Output

  object Output {
    case class Success200(data: String) extends Output

    object Success200 {
      implicit val encoder: io.circe.Encoder[Success200] = io.circe.Encoder.forProduct2("data", "statusCode")(s => (s.data, 1000))
      implicit val decoder: io.circe.Decoder[Success200] = new io.circe.Decoder[Success200] {
        final def apply(c: io.circe.HCursor): io.circe.Decoder.Result[Success200] =
          for {
            data <- c.downField("data").as[String]
            statusCode <- c.downField("statusCode").as[Int]
            res <- if (statusCode == 1000) Right(Success200(data))
            else Left(io.circe.DecodingFailure(s"Expected statusCode 1000, got $statusCode", c.history))
          } yield res
      }
    }

    sealed trait Error extends Output

    case class Error500(error: String) extends Error

    object Error500 {
      implicit val encoder: io.circe.Encoder[Error500] = io.circe.Encoder.forProduct2("error", "statusCode")(e => (e.error, 500))
      implicit val decoder: io.circe.Decoder[Error500] = new io.circe.Decoder[Error500] {
        final def apply(c: io.circe.HCursor): io.circe.Decoder.Result[Error500] =
          for {
            error <- c.downField("error").as[String]
            statusCode <- c.downField("statusCode").as[Int]
            res <- if (statusCode == 500) Right(Error500(error))
            else Left(io.circe.DecodingFailure(s"Expected statusCode 500, got $statusCode", c.history))
          } yield res
      }
    }

    case class Error200(error: String) extends Error

    object Error200 {
      implicit val encoder: io.circe.Encoder[Error200] = io.circe.Encoder.forProduct2("error", "statusCode")(e => (e.error, 2000))
      implicit val decoder: io.circe.Decoder[Error200] = new io.circe.Decoder[Error200] {
        final def apply(c: io.circe.HCursor): io.circe.Decoder.Result[Error200] =
          for {
            error <- c.downField("error").as[String]
            statusCode <- c.downField("statusCode").as[Int]
            res <- if (statusCode == 2000) Right(Error200(error))
            else Left(io.circe.DecodingFailure(s"Expected statusCode 2000, got $statusCode", c.history))
          } yield res
      }
    }
  }

  import sttp.tapir.json.circe._

  val testEndpoint: Endpoint[Unit, String, Output.Error, Output, Any] = endpoint.get
    .in("test")
    .in(path[String]("input"))
    .out(
      oneOf[Output](
        oneOfVariant(statusCode(StatusCode.Ok).and(jsonBody[Output.Success200])),

      )
    )
    .errorOut(
      oneOf[Output.Error](
        oneOfVariant(statusCode(StatusCode.InternalServerError).and(jsonBody[Output.Error500])),
        oneOfVariant(statusCode(StatusCode.Ok).and(jsonBody[Output.Error200]))
      )
    )
    .name("Test Endpoint")
    .description("A test endpoint for demonstration purposes")

  def logic(input: String): IO[Output.Error, Output.Success200] =
    input match {
      case "clientError" => ZIO.fail(Output.Error500("Client error"))
      case "ok" => ZIO.succeed(Output.Success200("Success!"))
      case _ => ZIO.fail(Output.Error200("This is a 200 error response"))
    }

  import sttp.tapir.ztapir._


  val routes: Routes[Any, Response] = ZioHttpInterpreter().toHttp(
    testEndpoint.zServerLogic(logic)
  )


  def runClient(input: String) = {
    import sttp.tapir.client.sttp4.SttpClientInterpreter
    for {
      sttpBackend <- ZIO.service[Backend[Task]]
      res <-
        SttpClientInterpreter().toClient(
            testEndpoint,
            Some(uri"http://localhost:8080"),
            sttpBackend
          ).apply(input)
          .flatMap {
            case DecodeResult.Value(Right(Output.Success200(data))) => ZIO.logInfo(s"Received success response: $data")
            case DecodeResult.Value(Left(Output.Error500(error))) => ZIO.logError(s"Received error 500: $error")
            case DecodeResult.Value(Left(Output.Error200(error))) => ZIO.logWarning(s"Received error 200: $error")
            case error  => ZIO.logErrorCause(s"Unexpected response: $error", Cause.fail(error))
          }
    } yield res

  }

  override def run: ZIO[Any with ZIOAppArgs with Scope, Any, Any] = {
    import io.circe.syntax._
    val e = implicitly[io.circe.Encoder[Output.Success200]]
    println(e.apply(Output.Success200("success")).spaces2)
    (ZIO.logInfo(Output.Success200("success").asJson.noSpaces) *>
      ZIO.logInfo(Output.Error500("error 500").asJson.noSpaces) *>
      ZIO.logInfo(Output.Error200("error 200 ").asJson.noSpaces) *>
      ZIO.logInfo("Starting server...") *>
      zio.http.Server.serve(routes)
        .provide(
          zio.http.Server.default) &>
      (ZIO.logInfo("And now starting the client...").delay(5.seconds) *>
        runClient("ok").catchAll(e => ZIO.logError(s"Client failed with error: $e")) *>
        runClient("clientError").catchAll(e => ZIO.logError(s"Client failed with error: $e")) *>
        runClient("anyOtherError").catchAll(e => ZIO.logError(s"Client failed with error: $e")))).provide(
      HttpClientZioBackend.layer()
    )
  }
}

This results in 2 successful calls, and one returning in a DecodeResult.Error coming from a Circe decode error:
JsonDecodeException(List(JsonError(Missing required field,List(FieldName(data,data)))),io.circe.Errors)

If you run the server, you will see that it responds to the 3 curls above as expected, but the generated client
can't cope with the fact that we have a successful status code (200) in the errorOut branch, and it tries to apply only the success decoder to the 200 response, without trying to explore what is defined in the errorOut branch.

If I change the second error branch from

        oneOfVariant(statusCode(StatusCode.Ok).and(jsonBody[Output.Error200]))

to

        oneOfVariant(statusCode(StatusCode.InternalServerError).and(jsonBody[Output.Error200]))

then the client works consistently with the server (but the server doesn't implement correctly the API anymore).

Should the client always explore also the errorOut branch, even when the response status code is successful?
Or would be a best practice to model the error related to the 200 http status code as a successful output (as an Either in the out channel, for example)?

Dependencies:
Tapir 1.11.40, Sttp client4 4.0.9, zio-http 3.3.3, zio 2.1.20

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions