Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove warning header; Configurable generated error responses (#2955) #3050

Merged
merged 3 commits into from
Aug 27, 2024

Conversation

987Nabil
Copy link
Contributor

  • I removed the typed warning header code. The intention is, to not encourage usage of a deprecated header.
  • I redesigned how error messages are created if generated from Throwable through internal code and certain user calls, like sandbox. The basic idea is, that the user can auto generate error responses and based on config the details are shown or not. The config is saved in a fiber ref. That makes it possible to not only set a global default, but override config based on the route or even the request. I added for that some middleware to support this in an easy way. For example, the following code will change the config based on a set header, to get maximum details for the error.
myRoute @@ ErrorResponseConfig.debug.whenHeader(_.get("zio-debug").contains("true"))
  • By default, the generated error responses only have a status code.
  • The error response will be in HTML, JSON or plaintext, based on the accept header^

fixes #2955
fixes #2701
fixes #2948

/claim #2955
/claim #2701
/claim #2948

attemptFastWrite(ctx, Response.fromThrowable(throwable))
attemptFastWrite(
ctx,
Response.fromThrowable(throwable, runtime.unsafeRunSync(ErrorResponseConfig.configRef.get)),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems a bit wasteful to start a fiber only to extract a FiberRef from it.

How about adding this method to NettyRuntime:

def getRef[A](ref: FiberRef[A]): A = zioRuntime.fiberRefs.getOrDefault(ref)

And then this becomes:

Response.fromThrowable(throwable, runtime.getRef(ErrorResponseConfig.configRef))

@@ -58,6 +58,9 @@ final case class Response(
self.copy(body = Body.fromChunk(bytes))
}

def contentType(json: MediaType): Response =
self.addHeader(Header.ContentType(json))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rendering of the ContentType header is quite expensive. For this reason, at the bottom of the the Response companion object the main 3 content-types are cached after calling untyped on them (which invokes the render method).

Might be better to pattern match here and check if the media type here matches one of those 3 and use the cached value or fallback to Header.ContentType.apply if not?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also the param name should be changed to mediaType

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it is just the best to use the untyped API. Probably the cheapest.

Copy link
Collaborator

@kyri-petrou kyri-petrou left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies, I missed some minor things in my last review. Otherwise LGTM

withErrorBody: Boolean = false,
withStackTrace: Boolean = false,
maxStackTraceDepth: Int = 10,
errorFormat: ErrorResponseConfig.ErrorFormat = ErrorResponseConfig.ErrorFormat.Html,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking plaintext might make more sense as a default as it accounts for a wider range of usecases (APIs & serving user content)

Middleware.runBefore(configRef.updateSome { case oldConfig if oldConfig != config => config })

private[http] lazy val configRef: FiberRef[ErrorResponseConfig] =
FiberRef.unsafe.make(ErrorResponseConfig())(Unsafe)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For performance reasons, FiberRefs use reference equality to determine whether a FiberRef was changed. It's probably not an issue but perhaps use default here just incase

ErrorResponseConfig.configRef.get.map { cfg =>
if (cfg.withErrorBody) {
val mediaType: MediaType = accept
.flatMap(_.mimeTypes.sortBy(mt => -mt.qFactor.getOrElse(1.0)).map(_.mediaType).collectFirst {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add an implicit Ordering in the companion object of Accept? 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also ensure it is always sorted.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Best to ensure it's always sorted, we don't want to be doing this on each request.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has to be done on each request, since this the the requests accept header. But since the amount of accepted media types will be small, I thought a sorted list might be easier to understand. It is just sorted by q factor when ever you deal with the typed interface.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you could check and avoid sorting it if it's already sorted, which would avoid the overhead entirely for the happy path.

@@ -58,6 +58,9 @@ final case class Response(
self.copy(body = Body.fromChunk(bytes))
}

def contentType(mediaType: MediaType): Response =
self.addHeader("content-type", mediaType.fullType)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outside the scope of this PR, but I'm wondering whether we should change the fullType to a val instead of a lazy val to avoid the penalty of lazy vals.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since both are static strings, I'll expect the val to be cheap anyway

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this would be scala 3 only, inline def is probably best?

Copy link
Collaborator

@kyri-petrou kyri-petrou Aug 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's tackle this in another issue/PR where we can revise the usages of lazy val in ZIO HTTP. I think there are quite a few unnecessary ones

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines 254 to 268
if (config.withStackTrace && throwable.getStackTrace.nonEmpty)
(if (config.maxStackTraceDepth == 0) throwable.getStackTrace
else throwable.getStackTrace.take(config.maxStackTraceDepth))
.mkString("\n", "\n", "")
else ""
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is getStackTrace cheap? Or should we rewrite this to invoke it only once?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll double check

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created a local val. I think we could optimize printing limited stack traces, since getStackTrace captures the full stack. We could probably walk the trace only until n. But I think this is okay for now.


final def handleErrorZIO(f: Err => ZIO[Any, Nothing, Response])(implicit trace: Trace): Route[Env, Nothing] =
self.handleErrorCauseZIO { cause =>
cause.failureOrCause match {
case Left(err) => f(err)
case Right(cause) => ZIO.succeed(Response.fromCause(cause))
case Right(cause) => ErrorResponseConfig.configRef.get.map(Response.fromCause(cause, _))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not as nice, but it requires 1 FlatMap less:

Suggested change
case Right(cause) => ErrorResponseConfig.configRef.get.map(Response.fromCause(cause, _))
case Right(cause) => ErrorResponseConfig.configRef.getWith(c => ZIO.succeed(Response.fromCause(cause, c)))

There are a few other places this can be applied to

Copy link
Contributor Author

@987Nabil 987Nabil Aug 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still nice enough

* maximum number of stack trace lines to include in the response body. Set to
* 0 to include all lines.
*/
case class ErrorResponseConfig(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
case class ErrorResponseConfig(
final case class ErrorResponseConfig(

@@ -549,7 +547,7 @@ sealed trait Handler[-R, +Err, -In, +Out] { self =>
self(in)

final def sandbox(implicit trace: Trace): Handler[R, Response, In, Out] =
self.mapErrorCause(Response.fromCause(_))
self.mapErrorCauseZIO(c => ErrorResponseConfig.configRef.get.map(Response.fromCause(c, _)).flip)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a fair amount of overhead here--of course, there was before, but now there is even more.

Could be useful to unsafely get the fiber ref value in as many places as you can "safely" bundle it up in lazy evaluation (like here).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to figure out, what you mean. But I don't get it.
Currently, this config is saved within a fiber ref. Therefore idk how to get it unsafe. Also idk if the call to sandbox needs to be that optimized? Don't we advertise this as a non-prod feature?
But what you try to say might be true for other places?

One other design option I thought of was, to use a ConcurrentHashMap with the route pattern as a key. That way we could avoid fiber refs and zio based access to our config storage.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah right, FiberRef instead of Ref.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering what the thought in using a fiber ref is? To make it compatible with scoped changes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. That you can override the behavior per route/request.

@987Nabil 987Nabil force-pushed the warning-headers branch 3 times, most recently from 3188189 to 8abbbee Compare August 27, 2024 20:26
@987Nabil 987Nabil force-pushed the warning-headers branch 3 times, most recently from 1a0595c to 6f6bbd2 Compare August 27, 2024 21:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
3 participants