Skip to content

Commit

Permalink
Simplify ResponseException (#2397)
Browse files Browse the repository at this point in the history
  • Loading branch information
adamw authored Jan 14, 2025
1 parent e992977 commit 0a2aaa9
Show file tree
Hide file tree
Showing 56 changed files with 571 additions and 462 deletions.
3 changes: 2 additions & 1 deletion core/src/main/scala/sttp/client4/Response.scala
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ case class Response[+T](
s"Response($body,$code,$statusText,${Headers.toStringSafe(headers)},$history,$request)"
}

private[sttp] object Response {
/** For testing, responses can be more conveniently created using [[ResponseStub]]. */
object Response {

def apply[T](body: T, code: StatusCode, requestMetadata: RequestMetadata): Response[T] =
Response(body, code, resolveStatusText(code), Nil, Nil, requestMetadata)
Expand Down
121 changes: 65 additions & 56 deletions core/src/main/scala/sttp/client4/ResponseAs.scala
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
package sttp.client4

import sttp.capabilities.{Effect, Streams, WebSockets}
import sttp.capabilities.Effect
import sttp.capabilities.Streams
import sttp.capabilities.WebSockets
import sttp.client4.ResponseException.DeserializationException
import sttp.client4.ResponseException.UnexpectedStatusCode
import sttp.client4.internal.SttpFile
import sttp.model.ResponseMetadata
import sttp.model.internal.Rfc3986
import sttp.ws.{WebSocket, WebSocketFrame}
import sttp.ws.WebSocket
import sttp.ws.WebSocketFrame

import java.io.InputStream
import scala.collection.immutable.Seq
import scala.util.{Failure, Success, Try}
import scala.util.Failure
import scala.util.Success
import scala.util.Try

/** Describes how the response body of a request should be handled. A number of `as<Type>` helper methods are available
* as part of [[SttpApi]] and when importing `sttp.client4._`. These methods yield specific implementations of this
Expand Down Expand Up @@ -77,30 +84,30 @@ case class ResponseAs[+T](delegate: GenericResponseAs[T, Any]) extends ResponseA
)

/** If the type to which the response body should be deserialized is an `Either[A, B]`:
* - in case of `A`, throws as an exception / returns a failed effect (wrapped with an [[HttpError]] if `A` is not
* yet an exception)
* - in case of `A`, throws as an exception / returns a failed effect (wrapped with an [[UnexpectedStatusCode]] if
* `A` is not yet an exception)
* - in case of `B`, returns the value directly
*/
def orFail[A, B](implicit tIsEither: T <:< Either[A, B]): ResponseAs[B] =
mapWithMetadata { case (t, meta) =>
(t: Either[A, B]) match {
case Left(a: Exception) => throw a
case Left(a) => throw HttpError(a, meta.code)
case Left(a) => throw UnexpectedStatusCode(a, meta)
case Right(b) => b
}
}

/** If the type to which the response body should be deserialized is an `Either[ResponseException[HE, DE], B]`, either
/** If the type to which the response body should be deserialized is an `Either[ResponseException[HE], B]`, either
* throws / returns a failed effect with the [[DeserializationException]], returns the deserialized body from the
* [[HttpError]], or the deserialized successful body `B`.
* [[UnexpectedStatusCode]], or the deserialized successful body `B`.
*/
def orFailDeserialization[HE, DE, B](implicit
tIsEither: T <:< Either[ResponseException[HE, DE], B]
def orFailDeserialization[HE, B](implicit
tIsEither: T <:< Either[ResponseException[HE], B]
): ResponseAs[Either[HE, B]] = map { t =>
(t: Either[ResponseException[HE, DE], B]) match {
case Left(HttpError(he, _)) => Left(he)
case Left(d: DeserializationException[_]) => throw d
case Right(b) => Right(b)
(t: Either[ResponseException[HE], B]) match {
case Left(UnexpectedStatusCode(he, _)) => Left(he)
case Left(d: DeserializationException) => throw d
case Right(b) => Right(b)
}
}

Expand All @@ -109,22 +116,22 @@ case class ResponseAs[+T](delegate: GenericResponseAs[T, Any]) extends ResponseA

object ResponseAs {

/** Returns a function, which maps `Left` values to [[HttpError]] s, and attempts to deserialize `Right` values using
* the given function, catching any exceptions and representing them as [[DeserializationException]] s.
/** Returns a function, which maps `Left` values to [[UnexpectedStatusCode]] s, and attempts to deserialize `Right`
* values using the given function, catching any exceptions and representing them as [[DeserializationException]] s.
*/
def deserializeRightCatchingExceptions[T](
doDeserialize: String => T
): (Either[String, String], ResponseMetadata) => Either[ResponseException[String, Exception], T] = {
case (Left(s), meta) => Left(HttpError(s, meta.code))
case (Right(s), _) => deserializeCatchingExceptions(doDeserialize)(s)
): (Either[String, String], ResponseMetadata) => Either[ResponseException[String], T] = {
case (Left(s), meta) => Left(UnexpectedStatusCode(s, meta))
case (Right(s), meta) => deserializeCatchingExceptions(doDeserialize)(s, meta)
}

/** Returns a function, which attempts to deserialize `Right` values using the given function, catching any exceptions
* and representing them as [[DeserializationException]] s.
*/
def deserializeCatchingExceptions[T](
doDeserialize: String => T
): String => Either[DeserializationException[Exception], T] =
): (String, ResponseMetadata) => Either[DeserializationException, T] =
deserializeWithError((s: String) =>
Try(doDeserialize(s)) match {
case Failure(e: Exception) => Left(e)
Expand All @@ -133,63 +140,65 @@ object ResponseAs {
}
)

/** Returns a function, which maps `Left` values to [[HttpError]] s, and attempts to deserialize `Right` values using
* the given function.
/** Returns a function, which maps `Left` values to [[UnexpectedStatusCode]] s, and attempts to deserialize `Right`
* values using the given function.
*/
def deserializeRightWithError[E: ShowError, T](
doDeserialize: String => Either[E, T]
): (Either[String, String], ResponseMetadata) => Either[ResponseException[String, E], T] = {
case (Left(s), meta) => Left(HttpError(s, meta.code))
case (Right(s), _) => deserializeWithError(doDeserialize)(implicitly[ShowError[E]])(s)
def deserializeRightWithError[T](
doDeserialize: String => Either[Exception, T]
): (Either[String, String], ResponseMetadata) => Either[ResponseException[String], T] = {
case (Left(s), meta) => Left(UnexpectedStatusCode(s, meta))
case (Right(s), meta) => deserializeWithError(doDeserialize)(s, meta)
}

/** Returns a function, which keeps `Left` unchanged, and attempts to deserialize `Right` values using the given
* function. If deserialization fails, an exception is thrown
*/
def deserializeRightOrThrow[E: ShowError, T](
doDeserialize: String => Either[E, T]
): Either[String, String] => Either[String, T] = {
case Left(s) => Left(s)
case Right(s) => Right(deserializeOrThrow(doDeserialize)(implicitly[ShowError[E]])(s))
def deserializeRightOrThrow[T](
doDeserialize: String => Either[Exception, T]
): (Either[String, String], ResponseMetadata) => Either[String, T] = {
case (Left(s), _) => Left(s)
case (Right(s), m) => Right(deserializeOrThrow(doDeserialize)(s, m))
}

/** Converts a deserialization function, which returns errors of type `E`, into a function where errors are wrapped
* using [[DeserializationException]].
*/
def deserializeWithError[E: ShowError, T](
doDeserialize: String => Either[E, T]
): String => Either[DeserializationException[E], T] =
s =>
def deserializeWithError[T](
doDeserialize: String => Either[Exception, T]
): (String, ResponseMetadata) => Either[DeserializationException, T] =
(s, meta) =>
doDeserialize(s) match {
case Left(e) => Left(DeserializationException(s, e))
case Left(e) => Left(DeserializationException(s, e, meta))
case Right(b) => Right(b)
}

/** Converts a deserialization function, which returns errors of type `E`, into a function where errors are thrown as
* exceptions, and results are returned unwrapped.
*/
def deserializeOrThrow[E: ShowError, T](doDeserialize: String => Either[E, T]): String => T =
s =>
def deserializeOrThrow[T](
doDeserialize: String => Either[Exception, T]
): (String, ResponseMetadata) => T =
(s, meta) =>
doDeserialize(s) match {
case Left(e) => throw DeserializationException(s, e)
case Left(e) => throw DeserializationException(s, e, meta)
case Right(b) => b
}

/** Converts deserialization functions, which both return errors of type `E`, into a function where errors are thrown
* as exceptions, and results are parsed using either of the functions, depending if the response was successfull, or
* as exceptions, and results are parsed using either of the functions, depending if the response was successful, or
* not.
*/
def deserializeEitherWithErrorOrThrow[E: ShowError, T, T2](
doDeserializeHttpError: String => Either[E, T],
doDeserializeHttpSuccess: String => Either[E, T2]
def deserializeEitherWithErrorOrThrow[T, T2](
doDeserializeHttpError: String => Either[Exception, T],
doDeserializeHttpSuccess: String => Either[Exception, T2]
): (String, ResponseMetadata) => Either[T, T2] =
(s, m) =>
if (m.isSuccess) Right(deserializeOrThrow(doDeserializeHttpSuccess).apply(s))
else Left(deserializeOrThrow(doDeserializeHttpError).apply(s))
if (m.isSuccess) Right(deserializeOrThrow(doDeserializeHttpSuccess).apply(s, m))
else Left(deserializeOrThrow(doDeserializeHttpError).apply(s, m))

/** Converts deserialization functions, which both throw exceptions upon errors, into a function where errors still
* thrown as exceptions, and results are parsed using either of the functions, depending if the response was
* successfull, or not.
* successful, or not.
*/
def deserializeEitherOrThrow[T, T2](
doDeserializeHttpError: String => T,
Expand Down Expand Up @@ -226,15 +235,15 @@ case class StreamResponseAs[+T, S](delegate: GenericResponseAs[T, S]) extends Re
StreamResponseAs(delegate.mapWithMetadata(f))

/** If the type to which the response body should be deserialized is an `Either[A, B]`:
* - in case of `A`, throws as an exception / returns a failed effect (wrapped with an [[HttpError]] if `A` is not
* yet an exception)
* - in case of `A`, throws as an exception / returns a failed effect (wrapped with an [[UnexpectedStatusCode]] if
* `A` is not yet an exception)
* - in case of `B`, returns the value directly
*/
def orFail[A, B](implicit tIsEither: T <:< Either[A, B]): StreamResponseAs[B, S] =
mapWithMetadata { case (t, meta) =>
(t: Either[A, B]) match {
case Left(a: Exception) => throw a
case Left(a) => throw HttpError(a, meta.code)
case Left(a) => throw UnexpectedStatusCode(a, meta)
case Right(b) => b
}
}
Expand Down Expand Up @@ -267,15 +276,15 @@ case class WebSocketResponseAs[F[_], +T](delegate: GenericResponseAs[T, Effect[F
WebSocketResponseAs(delegate.mapWithMetadata(f))

/** If the type to which the response body should be deserialized is an `Either[A, B]`:
* - in case of `A`, throws as an exception / returns a failed effect (wrapped with an [[HttpError]] if `A` is not
* yet an exception)
* - in case of `A`, throws as an exception / returns a failed effect (wrapped with an [[UnexpectedStatusCode]] if
* `A` is not yet an exception)
* - in case of `B`, returns the value directly
*/
def orFail[A, B](implicit tIsEither: T <:< Either[A, B]): WebSocketResponseAs[F, B] =
mapWithMetadata { case (t, meta) =>
(t: Either[A, B]) match {
case Left(a: Exception) => throw a
case Left(a) => throw HttpError(a, meta.code)
case Left(a) => throw UnexpectedStatusCode(a, meta)
case Right(b) => b
}
}
Expand Down Expand Up @@ -308,15 +317,15 @@ case class WebSocketStreamResponseAs[+T, S](delegate: GenericResponseAs[T, S wit
WebSocketStreamResponseAs[T2, S](delegate.mapWithMetadata(f))

/** If the type to which the response body should be deserialized is an `Either[A, B]`:
* - in case of `A`, throws as an exception / returns a failed effect (wrapped with an [[HttpError]] if `A` is not
* yet an exception)
* - in case of `A`, throws as an exception / returns a failed effect (wrapped with an [[UnexpectedStatusCode]] if
* `A` is not yet an exception)
* - in case of `B`, returns the value directly
*/
def orFail[A, B](implicit tIsEither: T <:< Either[A, B]): WebSocketStreamResponseAs[B, S] =
mapWithMetadata { case (t, meta) =>
(t: Either[A, B]) match {
case Left(a: Exception) => throw a
case Left(a) => throw HttpError(a, meta.code)
case Left(a) => throw UnexpectedStatusCode(a, meta)
case Right(b) => b
}
}
Expand Down
92 changes: 43 additions & 49 deletions core/src/main/scala/sttp/client4/ResponseException.scala
Original file line number Diff line number Diff line change
@@ -1,62 +1,56 @@
package sttp.client4

import sttp.model.StatusCode

import scala.annotation.tailrec
import sttp.model.ResponseMetadata

/** Used to represent errors, that might occur when handling the response body. Typically, this type is used as the
* left-side of a top-level either (where the right-side represents a successfull request and deserialization).
/** Used to represent errors, that might occur when handling the response body, that was otherwise received
* successfully.
*
* A response exception can itself be one of two cases:
* - [[ResponseException.UnexpectedStatusCode]], when the response code is other than 2xx (or whatever is considered
* "success" by the response handling description); the body is deserialized to `HE`
* - [[ResponseException.DeserializationException]], when there's an error during deserialization (this includes
* deserialization exceptions of both the success and error branches)
*
* A response exception can itself either be one of two cases:
* - a [[HttpError]], when the response code is other than 2xx (or whatever is considered "success" by the response
* handling description); the body is deserialized to `HE`
* - a [[DeserializationException]], when there's an error during deserialization (this might include both
* deserialization exceptions of the success and error branches)
* This type is often used as the left-side of a top-level either (where the right-side represents a successful request
* and deserialization). When thrown/returned when sending a request (e.g. in `...OrFailed` response handling
* descriptions), will be additionally wrapped with a [[SttpClientException.ResponseHandlingException]].
*
* @tparam HE
* The type of the body to which the response is deserialized, when the response code is different than success
* (typically 2xx status code).
* @tparam DE
* A deserialization-library-specific error type, describing the deserialization error in more detail.
*/
sealed abstract class ResponseException[+HE, +DE](error: String, cause: Option[Throwable])
extends Exception(error, cause.orNull)

/** Represents an http error, where the response was received successfully, but the status code is other than the
* expected one (typically other than 2xx).
*
* @tparam HE
* The type of the body to which the error response is deserialized.
*/
case class HttpError[+HE](body: HE, statusCode: StatusCode)
extends ResponseException[HE, Nothing](s"statusCode: $statusCode, response: $body", None)

/** Represents an error that occured during deserialization of `body`.
*
* @tparam DE
* A deserialization-library-specific error type, describing the deserialization error in more detail.
*/
case class DeserializationException[+DE: ShowError](body: String, error: DE)
extends ResponseException[Nothing, DE](
implicitly[ShowError[DE]].show(error),
if (error.isInstanceOf[Throwable]) Some(error.asInstanceOf[Throwable]) else None
)

object HttpError {
@tailrec def find(exception: Throwable): Option[HttpError[_]] =
sealed abstract class ResponseException[+HE](
error: String,
cause: Option[Throwable],
val response: ResponseMetadata
) extends Exception(error, cause.orNull)

object ResponseException {

/** Represents an error, where the response was received successfully, but the status code is other than the expected
* one (typically other than 2xx).
*
* @tparam HE
* The type of the body to which the error response is deserialized.
*/
case class UnexpectedStatusCode[+HE](body: HE, override val response: ResponseMetadata)
extends ResponseException[HE](s"statusCode: ${response.code}, response: $body", None, response)

/** Represents an error that occurred during deserialization of `body`. */
case class DeserializationException(body: String, cause: Exception, override val response: ResponseMetadata)
extends ResponseException[Nothing](
cause.getMessage(),
Some(cause),
response
)

//

@tailrec def find(exception: Throwable): Option[ResponseException[_]] =
Option(exception) match {
case Some(error: HttpError[_]) => Some(error)
case Some(_) => find(exception.getCause)
case None => Option.empty
case Some(e: ResponseException[_]) => Some(e)
case Some(_) => find(exception.getCause)
case None => None
}
}

trait ShowError[-T] {
def show(t: T): String
}

object ShowError {
implicit val showErrorMessageFromException: ShowError[Exception] = new ShowError[Exception] {
override def show(t: Exception): String = t.getMessage
}
}
Loading

0 comments on commit 0a2aaa9

Please sign in to comment.