-
Notifications
You must be signed in to change notification settings - Fork 447
Description
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.
- 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
}
- 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
}
- 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 curl
s 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