scalaerror-handlingplayframeworkplay-jsonws-client

Processing JSON error responses with Play WSClient


I'm using Play's WSClient to interact with a third-party service

request = ws.url(baseUrl)
  .post(data)
  .map{ response =>
     response.json.validate[MyResponseClass]

The response may be a MyResponseClass or it may be an ErrorResponse like { "error": [ { "message": "Error message" } ] }

Is there a typical way to parse either the Class or the Error?

Should i do something like this?

response.json.validateOpt[MyResponseClass].getOrElse(response.json.validateOpt[ErrorClass])

Solution

  • There is no single answer to this problem. There are multiple subtle considerations here. My answer will attempt to provide some direction.

    At least four different cases to handle:

    1. Application level valid results (connection established, response received, 200 status code)
    2. Application level errors (connection established, response received, 4xx, 5xx status code)
    3. Networking IO errors (connection not established, or no response received due to timeout etc.)
    4. JSON parsing errors (connection established, response received, failed to convert JSON to model domain object)

    Pseudocode:

    1. Completed Future with response inside which is either ErrorResponse or MyResponseClass, that is, Either[ErrorResponse, MyResponseClass]:

      1. If service returns 200 status code, then parse as MyResponseClass
      2. If service returns >= 400 status code, then parse as ErrorResponse
    2. Completed Future with exception inside:

      1. Parsing Exception, or
      2. Networking IO Exception (for example timeout)

    Future(Left(errorResponse)) vs. Future(throw new Exception)

    Note the difference between Future(Left(errorResponse)) and Future(throw new Exception): we consider only the latter as a failed future. The former, despite having a Left inside is still consider a successfully completed future.

    Future.andThen vs Future.recover

    Note the difference between Future.andThen and Future.recover: former does not alter the value inside the future, while the latter can alter the value inside and its type. If recovery is impossible we could at least log exceptions using andThen.

    Example:

    import akka.actor.ActorSystem
    import akka.stream.ActorMaterializer
    import play.api.libs.ws._
    import play.api.libs.ws.ahc._
    import scala.concurrent.ExecutionContext.Implicits._
    import scala.concurrent.Future
    import play.api.libs.json._
    import play.api.libs.ws.JsonBodyReadables._
    import scala.util.Failure
    import java.io.IOException
    import com.fasterxml.jackson.core.JsonParseException
    
    case class ErrorMessage(message: String)
    
    object ErrorMessage {
      implicit val errorMessageFormat = Json.format[ErrorMessage]
    }
    
    case class ErrorResponse(error: List[ErrorMessage])
    
    object ErrorResponse {
      implicit val errorResponseFormat = Json.format[ErrorResponse]
    }
    
    case class MyResponseClass(a: String, b: String)
    
    object MyResponseClass {
      implicit val myResponseClassFormat = Json.format[MyResponseClass]
    }
    
    object PlayWsErrorHandling extends App {
        implicit val system = ActorSystem()
        implicit val materializer = ActorMaterializer()
    
        val wsClient = StandaloneAhcWSClient()
    
        httpRequest(wsClient) map {
          case Left(errorResponse) =>
            println(s"handle application level error: $errorResponse")
            // ...
    
          case Right(goodResponse) =>
            println(s"handle application level good response $goodResponse")
            // ...
    
        } recover { // handle failed futures (futures with exceptions inside)
          case parsingError: JsonParseException =>
            println(s"Attempt recovery from parsingError")
            // ...
    
          case networkingError: IOException =>
            println(s"Attempt recovery from networkingError")
            // ...
        }
    
      def httpRequest(wsClient: StandaloneWSClient): Future[Either[ErrorResponse, MyResponseClass]] =
        wsClient.url("http://www.example.com").get() map { response ⇒
    
          if (response.status >= 400) // application level error
            Left(response.body[JsValue].as[ErrorResponse])
          else // application level good response
            Right(response.body[JsValue].as[MyResponseClass])
    
        } andThen { // exceptions thrown inside Future
          case Failure(exception) => exception match {
            case parsingError: JsonParseException => println(s"Log parsing error: $parsingError")
            case networkingError: IOException => println(s"Log networking errors: $networkingError")
          }
        }
    }
    

    Dependencies:

    libraryDependencies ++= Seq(
      "com.typesafe.play" %% "play-ahc-ws-standalone"   % "1.1.3",
      "com.typesafe.play" %% "play-ws-standalone-json"  % "1.1.3"
    )