softwaremill / sttp

The Scala HTTP client you always wanted!
https://sttp.softwaremill.com
Apache License 2.0
1.44k stars 301 forks source link

sttp2: response-as changes #284

Closed adamw closed 4 years ago

adamw commented 5 years ago

The main pain point of sttp1, and a common source of confusion is the way errors are represented in the response body. For example, when parsing a response as json using circe, we get a cryptic type:

sttp.response(asJson).(...).send().body: 
  Either[String, Either[DeserializationError[io.circeError], T]]

It's far from clear what the nested eithers correspond to (the outside either corresponds to http errors, while the inner one to json parsing errors).

Further, the way errors are handled is currently hard-coded: in case of a non-2xx response, the body is read as a byte array. There are some ways to change that (using request.parseResponseIf), but they are still limited.

Hence the proposed change for sttp2, which is a major breaking change (source code will need to be adjusted).

The basic idea is to remove the assumptions on how errors should be handled. A Request[T, ...] will now result in a response, where the body has type T, regardless of the status code. Second, apart from mapping over response specifications, it will be possible to dynamically decide (basing on the headers & status code) on how to read the response. This brings a lot of flexibility.

However, what about the common-case? Usually, when sending a request, the error and success cases should be handled separately. So for most requests, we still want the response body to have type Either[String, T], where String corresponds to the error case, and T to the successful one.

That's why the basic response specifications are now changed. For example, asString: ResponseAs[Either[String, String]], asFile: ResponseAs[Either[String, File]] etc. Errors are represented at the top-level, right in the request specification. This has one important implication: the type of sttp, as well as many requests derived from it will change. What was before a Request[T, ...], will now most probably become Request[Either[String, T], ...].

That's the problematic part. What do we gain?

  1. flexibility. If we want to read the response body as a string always, we specify this: sttp.response(asStringAlways), and then the reponse's body will be a plain String: response.body: String. Same for byte arrays, streams etc.
  2. readable json errors. The json parsers now return a response descripition of type ResponseAs[Either[ResponseError[E], T]. The top-level Either remains to signal if the request was successful or not. However, both kinds of errors (http and deserialization) are represented as a left value, without the nesting: ResponseError has two implementations, HttpError and DeserializationError.

What do you think? Any ideas to further improve response handling? Or should the current way remain?

Related commit: https://github.com/softwaremill/sttp/commit/6d59d7355f61dfa97756dab71f56359477fc91aa

aappddeevv commented 5 years ago

In some http protocols, when there is an error, there is a json formatted error body that contains the error information in addition to the status code being 4xx or 5xx, etc. It was not clear that the proposal above makes that an easy case to code for. I'd prefer to have a type error but one that potentially has a different hierarchy than Throwable. Unless you are explicitly supporting an effect that allows a non-Throwable hierarchy in its error channel or have some other clever approach for non-effectful backends, it seems a left string really does not make it much easier since I would still have to run a json parser against it.

adamw commented 5 years ago

@aappddeevv good point! That case would be representable as well, but you'd to adjust the json-deserializing code a bit. Maybe we could improve the hierarchy to make this easier.

So the default hierarchy as it is now is:

trait ResponseError[+E] extends Exception
case class HttpError(body: String) extends ResponseError[Nothing]
case class DeserializationError[E] extends ResponseError[E]

Alternatively, it could be sth like:

trait ResponseError[+E, +T] extends Exception
case class HttpError(body: T) extends ResponseError[Nothing, T]
case class DeserializationError[E] extends ResponseError[E, Nothing]

The asJson response description would then have the type:

def asJson[B: Decoder]: ResponseAs[Either[ResponseError[String, io.circe.Error], B], Nothing]

And we could introduce a variant to decode errors as well:

def asJsonDecodeErrors[E: Decoder, B: Decoder]: ResponseAs[Either[ResponseError[E, io.circe.Error], B], Nothing]

What do you think?


By the way, there's always an escape hatch. You can always use asStringAlways: ResponseAs[String] and using .mapWithMetadata return any type you want (having information about the status code)

adamw commented 5 years ago

The above would essentially make ResponseError into a poor Either. Not sure if that's a good way ... maybe we need a three-state top-level result? Sth like:

sealed trait ResponseResult[+E, +H, +B]
case class Ok[B](body: B) extends ResponseResult[B, Nothing, Nothing]
case class HttpError(error: E) extends ResponseResult[Nothing, E, Nothing]
case class DeserialisationError(original: String, error: H) extends ResponseResult[Nothing, Nothing, H]
aappddeevv commented 5 years ago

I think as long as your effect is optional e.g. not async, and you don't have an explicit error channel in your effect you will have a poor man's either floating around. You may want to figure out if most people are sync or async then optimize for the most frequent usage. I don't have a good answer for this, all I know is that it is hard.

adamw commented 5 years ago

That's yet another category of errors: connection exceptions which are propagated through the effect.

I suppose most people use async, but both use-cases need to be covered. However the main difference here is that sync throws exceptions (when there are connection problems), while async wraps them in the effect

adamw commented 5 years ago

@aappddeevv after some consideration I think I'm going to leave the hierarchy as-is. But I've restructured the code so that it should be easy to re-use the existing parts.

For the errors-as-json case, you would have to create a custom ResponseAs description, capturing the results in a custom three- or four-state hierarchy (json error, success, deserialization error, and optional http error not covered by json).

This would look sth like this:

import com.softwaremill.sttp.json.circe._

asStringAlways.mapWithMetadata { (body, metadata) =>
  if (metadata.isSuccess) {
    eitherToCustomJsonSuccess(deserializeJson[SUCCESS_BODY](body))
  } else if (metadata.isClientError) {
    eitherToCustomJsonError(deserializeJson[ERROR_BODY](body))
  } else {
    CustomHttpError(body)
  }
}

Of course that would go into the docs :)

aappddeevv commented 4 years ago

Hmmm..I'm not sure that's the right answer. It feels highly imperative...which I'm not against though. I think there was some cats article on their site about hierarchy of errors.

adamw commented 4 years ago

@aappddeevv do you mean https://typelevel.org/blog/2018/08/25/http4s-error-handling-mtl.html ? Combining errors is a tricky subject (see also ZIO). I think union types will help a lot, but until then :)

I would say that the above is not declarative. But then, success/error criteria vary and are hard to capture declaratively.

aappddeevv commented 4 years ago

It was some article the cats github docs site...somewhere in https://typelevel/cats. I can't seem to find it now. Anyway, maybe its best not to try anything clever quite yet because its so hard as you mention.

jatcwang commented 4 years ago

👍 👍 from me. This is the one of the main reasons why we found using sttp a bit cumbersome (but it's the best of the bunch!), as the types were being too restrictive/opinionated, resulting in a lot of "postprocessing" that we had to do to make the return type actually what we want. Good to see that this is being addressed!

adamw commented 4 years ago

Released in 2.0.0-M5