Skip to content

Commit

Permalink
Rename HttpError to UnexpectedStatusCode, move to ResponseException o…
Browse files Browse the repository at this point in the history
…bject
  • Loading branch information
adamw committed Jan 14, 2025
1 parent 2c64d10 commit 60048d9
Show file tree
Hide file tree
Showing 36 changed files with 177 additions and 119 deletions.
53 changes: 30 additions & 23 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,28 +84,28 @@ 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)
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], 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, B](implicit
tIsEither: T <:< Either[ResponseException[HE], B]
): ResponseAs[Either[HE, B]] = map { t =>
(t: Either[ResponseException[HE], B]) match {
case Left(HttpError(he, _)) => Left(he)
case Left(UnexpectedStatusCode(he, _)) => Left(he)
case Left(d: DeserializationException) => throw d
case Right(b) => Right(b)
}
Expand All @@ -109,13 +116,13 @@ 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], T] = {
case (Left(s), meta) => Left(HttpError(s, meta))
case (Left(s), meta) => Left(UnexpectedStatusCode(s, meta))
case (Right(s), meta) => deserializeCatchingExceptions(doDeserialize)(s, meta)
}

Expand All @@ -133,13 +140,13 @@ 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[T](
doDeserialize: String => Either[Exception, T]
): (Either[String, String], ResponseMetadata) => Either[ResponseException[String], T] = {
case (Left(s), meta) => Left(HttpError(s, meta))
case (Left(s), meta) => Left(UnexpectedStatusCode(s, meta))
case (Right(s), meta) => deserializeWithError(doDeserialize)(s, meta)
}

Expand Down Expand Up @@ -228,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)
case Left(a) => throw UnexpectedStatusCode(a, meta)
case Right(b) => b
}
}
Expand Down Expand Up @@ -269,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)
case Left(a) => throw UnexpectedStatusCode(a, meta)
case Right(b) => b
}
}
Expand Down Expand Up @@ -310,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)
case Left(a) => throw UnexpectedStatusCode(a, meta)
case Right(b) => b
}
}
Expand Down
43 changes: 23 additions & 20 deletions core/src/main/scala/sttp/client4/ResponseException.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import sttp.model.ResponseMetadata
* successfully.
*
* A response exception can itself 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 includes deserialization
* exceptions of both the success and error branches)
* - [[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)
*
* 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
Expand All @@ -26,24 +26,27 @@ sealed abstract class ResponseException[+HE](
val response: ResponseMetadata
) 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, override val response: ResponseMetadata)
extends ResponseException[HE](s"statusCode: ${response.code}, response: $body", None, response)
object ResponseException {

/** 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
)
/** 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
)

//

object ResponseException {
@tailrec def find(exception: Throwable): Option[ResponseException[_]] =
Option(exception) match {
case Some(e: ResponseException[_]) => Some(e)
Expand Down
19 changes: 10 additions & 9 deletions core/src/main/scala/sttp/client4/SttpApi.scala
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ trait SttpApi extends SttpExtensions with UriInterpolator {
val basicRequest: PartialRequest[Either[String, String]] =
emptyRequest.acceptEncoding("gzip, deflate")

/** A starting request which always reads the response body as a string, if the response code is successfull (2xx),
* and fails (throws an exception, or returns a failed effect) otherwise.
/** A starting request which always reads the response body as a string, if the response code is successful (2xx), and
* fails (throws an exception, or returns a failed effect) otherwise.
*/
val quickRequest: PartialRequest[String] = basicRequest.response(asStringOrFail)

Expand Down Expand Up @@ -96,8 +96,8 @@ trait SttpApi extends SttpExtensions with UriInterpolator {
}
.showAs("as string")

/** Reads the response as a `String`, if the status code is 2xx. Otherwise, throws an [[HttpError]] / returns a failed
* effect. Use the `utf-8` charset by default, unless specified otherwise in the response headers.
/** Reads the response as a `String`, if the status code is 2xx. Otherwise, throws an [[UnexpectedStatusCode]] /
* returns a failed effect. Use the `utf-8` charset by default, unless specified otherwise in the response headers.
*
* @see
* the [[ResponseAs#orFail]] method can be used to convert any response description which returns an `Either` into
Expand All @@ -116,7 +116,7 @@ trait SttpApi extends SttpExtensions with UriInterpolator {
def asByteArrayAlways: ResponseAs[Array[Byte]] = ResponseAs(ResponseAsByteArray)

/** Reads the response as an array of bytes, without any processing, if the status code is 2xx. Otherwise, throws an
* [[HttpError]] / returns a failed effect.
* [[UnexpectedStatusCode]] / returns a failed effect.
*
* @see
* the [[ResponseAs#orFail]] method can be used to convert any response description which returns an `Either` into
Expand Down Expand Up @@ -148,8 +148,9 @@ trait SttpApi extends SttpExtensions with UriInterpolator {
asStringAlways(charset2).map(GenericResponseAs.parseParams(_, charset2)).showAs("as params")
}

/** Deserializes the response as form parameters, if the status code is 2xx. Otherwise, throws an [[HttpError]] /
* returns a failed effect. Uses the `utf-8` charset by default, unless specified otherwise in the response headers.
/** Deserializes the response as form parameters, if the status code is 2xx. Otherwise, throws an
* [[UnexpectedStatusCode]] / returns a failed effect. Uses the `utf-8` charset by default, unless specified
* otherwise in the response headers.
*
* @see
* the [[ResponseAs#orFail]] method can be used to convert any response description which returns an `Either` into
Expand Down Expand Up @@ -284,8 +285,8 @@ trait SttpApi extends SttpExtensions with UriInterpolator {
asEither(asStringAlways, asStreamAlways(s)(f))

/** Handles the response body by providing a stream with the response's data to `f`, if the status code is 2xx.
* Otherwise, returns a failed effect (with [[HttpError]]). The effect type used by `f` must be compatible with the
* effect type of the backend. The stream is always closed after `f` completes.
* Otherwise, returns a failed effect (with [[UnexpectedStatusCode]]). The effect type used by `f` must be compatible
* with the effect type of the backend. The stream is always closed after `f` completes.
*
* A non-blocking, asynchronous streaming implementation must be provided as the [[Streams]] parameter.
*
Expand Down
6 changes: 5 additions & 1 deletion core/src/main/scala/sttp/client4/SttpClientException.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,16 @@ import sttp.monad.MonadError
* @param cause
* The original exception.
*/
abstract class SttpClientException(val request: GenericRequest[_, _], val cause: Exception)
sealed abstract class SttpClientException(val request: GenericRequest[_, _], val cause: Exception)
extends Exception(s"Exception when sending request: ${request.method} ${request.uri}", cause)

object SttpClientException extends SttpClientExceptionExtensions {
class ConnectException(request: GenericRequest[_, _], cause: Exception) extends SttpClientException(request, cause)

class ReadException(request: GenericRequest[_, _], cause: Exception) extends SttpClientException(request, cause)

//

class TimeoutException(request: GenericRequest[_, _], cause: Exception) extends ReadException(request, cause)

class TooManyRedirectsException(request: GenericRequest[_, _], val redirects: Int)
Expand All @@ -42,6 +44,8 @@ object SttpClientException extends SttpClientExceptionExtensions {
class ResponseHandlingException[+HE](request: GenericRequest[_, _], val responseException: ResponseException[HE])
extends ReadException(request, responseException)

//

def adjustExceptions[F[_], T](
monadError: MonadError[F]
)(t: => F[T])(usingFn: Exception => Option[Exception]): F[T] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ trait SttpWebSocketAsyncApi {
asWebSocketEither(asStringAlways, asWebSocketAlways(f))

/** Handles the response as a web socket, providing an open [[WebSocket]] instance to the `f` function, if the status
* code is 2xx. Otherwise, returns a failed effect (with [[HttpError]]).
* code is 2xx. Otherwise, returns a failed effect (with [[]]).
*
* The effect type used by `f` must be compatible with the effect type of the backend. The web socket is always
* closed after `f` completes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ trait SttpWebSocketStreamApi {

/** Handles the response as a web socket, using the given `p` stream processing pipe to handle the incoming & produce
* the outgoing web socket frames, if the status code is 2xx. Otherwise, returns a failed effect (with
* [[HttpError]]).
* [[UnexpectedStatusCode]]).
*
* The effect type used by `f` must be compatible with the effect type of the backend. The web socket is always
* closed after `p` completes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ trait SttpWebSocketSyncApi {
asWebSocketEither(asStringAlways, asWebSocketAlways(f))

/** Handles the response as a web socket, providing an open [[WebSocket]] instance to the `f` function, if the status
* code is 2xx. Otherwise, throws an [[HttpError]].
* code is 2xx. Otherwise, throws an [[UnexpectedStatusCode]].
*
* The web socket is always closed after `f` completes.
*
Expand Down
1 change: 1 addition & 0 deletions core/src/test/scala/sttp/client4/LogTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import sttp.shared.Identity
import scala.collection.immutable.Seq
import scala.collection.mutable
import sttp.client4.testing.ResponseStub
import sttp.client4.ResponseException.DeserializationException

class LogTests extends AnyFlatSpec with Matchers with BeforeAndAfter {
private class SpyLogger extends Logger[Identity] {
Expand Down
16 changes: 11 additions & 5 deletions core/src/test/scala/sttp/client4/testing/BackendStubTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ package sttp.client4.testing
import org.scalatest.concurrent.ScalaFutures
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import sttp.client4.SttpClientException.ReadException
import sttp.client4._
import sttp.client4.ResponseException.DeserializationException
import sttp.client4.SttpClientException.ReadException
import sttp.client4.internal._
import sttp.client4.ws.async._
import sttp.model._
import sttp.monad.{FutureMonad, IdentityMonad, MonadError, TryMonad}
import sttp.monad.FutureMonad
import sttp.monad.IdentityMonad
import sttp.monad.MonadError
import sttp.monad.TryMonad
import sttp.shared.Identity
import sttp.ws.WebSocketFrame
import sttp.ws.testing.WebSocketStub
Expand All @@ -18,7 +22,9 @@ import java.util.concurrent.TimeoutException
import java.util.concurrent.atomic.AtomicInteger
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._
import scala.util.{Failure, Success, Try}
import scala.util.Failure
import scala.util.Success
import scala.util.Try

class BackendStubTests extends AnyFlatSpec with Matchers with ScalaFutures {
private val testingStub = SyncBackendStub
Expand Down Expand Up @@ -114,8 +120,8 @@ class BackendStubTests extends AnyFlatSpec with Matchers with ScalaFutures {
)
.send(testingBackend)

val readException = the[sttp.client4.SttpClientException.ReadException] thrownBy request()
readException.cause shouldBe a[sttp.client4.DeserializationException]
val readException = the[SttpClientException.ReadException] thrownBy request()
readException.cause shouldBe a[DeserializationException]
}

it should "use rules in partial function" in {
Expand Down
2 changes: 1 addition & 1 deletion core/src/test/scala/sttp/client4/testing/HttpTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ trait HttpTest[F[_]]
}
.toFuture()
.map(
_ shouldBe "sttp.client4.HttpError: statusCode: 400, response: POST /echo/custom_status/400 this is the body"
_ shouldBe "sttp.client4.ResponseException.UnexpectedStatusCode: statusCode: 400, response: POST /echo/custom_status/400 this is the body"
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ abstract class StreamingTest[F[_], S]
}
.toFuture()
.map(
_ shouldBe "sttp.client4.HttpError: statusCode: 400, response: POST /echo/custom_status/400 streaming test"
_ shouldBe "sttp.client4.ResponseException.UnexpectedStatusCode: statusCode: 400, response: POST /echo/custom_status/400 streaming test"
)
}

Expand Down
Loading

0 comments on commit 60048d9

Please sign in to comment.