Conversation
… compile (zio#3141) Handler.@@ casts the handler's input to Request and requires an IsRequest[In1] to justify the cast. On a route with path parameters the handler's input is a tuple such as (String, Request), for which there is no IsRequest instance. IsRequest was declared contravariant, so IsRequest[Request] also conformed to IsRequest[Nothing]. Since In1 is only bounded by In1 <: In, the compiler inferred In1 = Nothing, found the implicit, and let the unsound cast through — failing at runtime with: java.lang.ClassCastException: class zio.http.Request cannot be cast to class scala.Tuple2 Making IsRequest invariant removes the instance the inference was relying on, so the call is rejected at compile time instead.
✅ Deploy Preview for zio-http ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
There was a problem hiding this comment.
Pull request overview
This PR fixes an unsound Handler.@@ application by making Handler.IsRequest invariant so that applying handler aspects to routes whose handler input is a path-parameter tuple fails at compile time instead of producing a runtime ClassCastException.
Changes:
- Change
Handler.IsRequestvariance from contravariant to invariant to preventIn1 = Nothingfallback implicit resolution. - Add a regression test asserting that applying an aspect to a path-parameter route does not typecheck.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| zio-http/shared/src/main/scala/zio/http/Handler.scala | Makes IsRequest invariant to prevent unsound @@ casts from compiling. |
| zio-http/jvm/src/test/scala/zio/http/HandlerAspectSpec.scala | Adds a compile-time regression test for the path-parameter + aspect case (#3141). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private val errorMediaTypes = List(MediaType.text.html, MediaType.application.json, MediaType.text.plain) | ||
|
|
||
| sealed trait IsRequest[-A] | ||
| sealed trait IsRequest[A] |
The mdoc snippet used a bare `Handler.identity`, whose `In` inferred to `Nothing`. That only typechecked because `IsRequest` was contravariant — the same inference hole this PR closes — so the documented example was relying on the unsound path. Replaced it with a handler that is explicitly `Request => Response`, which is what the surrounding prose describes.
|
CI caught something that makes this PR's case both stronger and more expensive, so reporting it against myself. The build failed on The snippet is: val myHandler = Handler.identity // In infers to Nothing
myHandler @@ HandlerAspect.fail(Response.forbidden("Access Denied!"))
Two honest readings of that:
I fixed the doc by giving the example the type its surrounding prose already describes — But I do not want to hide the trade behind a green build. The precise scope is: any If that is too wide for a 3.x line, the narrower alternative is to leave Updated verification, all local:
|
|
CI is green on everything my change affects. The one remaining failure is pre-existing and I can show that rather than assert it.
The remaining red is
Same spec, same job, same
Checks that are green and do cover this change:
And locally, across every Scala version in the CI matrix:
I'd rather not re-push to chase a red that isn't mine, but say the word if you want it rebased once |
|
Correction, and it goes against this PR. I found #3150 — @987Nabil's own PR from 2024-09, closed unmerged — after opening this. I should have found it first, and I want to put the comparison on the record rather than let this sit as if it were the only proposal. You had already diagnosed the same thing I did. From that PR's description:
That is exactly the finding I wrote up above, two years later. Your fix is better than mine. You replaced the guard rather than repairing it: def @@[Env1 <: R, In1 <: In](aspect: HandlerAspect[Env1, Unit])(implicit
- in: Handler.IsRequest[In1],
+ ev: Request <:< In,The difference matters. My change makes implicitly[Request <:< Nothing]
// Cannot prove that zio.http.Request <:< Nothing.It also explains the regression I reported against myself earlier in this thread. Your 2024 assessment of the cost was also more accurate than mine:
I claimed Mima passes for my version, and it does — but that is a property of moving variance on a marker trait, not evidence the change is safe. Yours is honest about being breaking, which is the real question either way. So: if you want this fixed, #3150 is the better patch, and this PR should be closed in its favour. The one thing I can add is that #3150 also introduced Either way I'd rather you merge the right fix than mine. Let me know which and I'll do the work; if the answer is "still not for 3.x", that is a fine answer and I'll close this. |
|
Followed up on my own suggestion and tested it, so the choice is backed by numbers rather than my say-so. Reviving #3150 wholesale does not workI fetched it and merged onto current #3150 also removed Extracting just the guard change does workThe part that matters is four lines: def @@[Env1 <: R, In1 <: In](aspect: HandlerAspect[Env1, Unit])(implicit
- in: Handler.IsRequest[In1],
+ ev: Request <:< In,(twice in
Behaviour, verified against a locally published build:
Being straight about the last rowThe docs snippet breaks under both approaches — mine and yours. I said earlier that What differs is the diagnostic. Mine says "could not find implicit value for parameter in: Handler.IsRequest[In1]" — a missing instance for an internal marker trait, which tells the user nothing. Yours says "Cannot prove that Request <:< Nothing", which names the actual problem and points at the fix (annotate the handler). For a source-breaking change, the error message is most of the cost. So the guard change is still the better patch, just not for the reason I gave. What I'd suggestClose #4229 and take the four-line guard change instead. I can open it as a fresh PR against Still your call whether it belongs in 3.x at all. It is source-breaking, and your 2024 note said as much. I'd just rather that decision be made against the right patch. |
|
Closing in favour of #4230, which uses @987Nabil's guard design from #3150 instead of my variance patch. For the record, why that one is better:
Both close the Thanks for the patience with the back-and-forth on this one. |
Fixes #3141.
/claim #3141Root cause
Handler.@@casts the handler's input toRequest, and requires anIsRequest[In1]to justify that cast:On a route with path parameters the handler's input is a tuple — for the issue's reproducer,
Handler[..., (String, Request), Response]— and there is deliberately noIsRequestinstance for tuples. I confirmed that directly against 3.11.2:So the guard is correct — it just wasn't being asked about the right type.
IsRequestwas contravariant:Contravariance means
IsRequest[Request] <: IsRequest[Nothing], and this compiles:In1is only bounded byIn1 <: In, andNothingsatisfies any such bound. So on a tuple-input handler the compiler infersIn1 = Nothingrather than(String, Request), the implicit resolves, and theasInstanceOfgoes through unchecked — surfacing at runtime as:The fix
With
IsRequestinvariant,IsRequest[Nothing]no longer exists, so there is no instance for the compiler to fall back to and the application is rejected where it should be — at compile time.Verification
Reproducer from the issue, unchanged, against 3.11.2:
Same file against this branch (
publishLocal):Locally, on both Scala versions:
zioHttpJVM/Test/compileHandlerAspectSpecHandlerSpec+RoutesSpec+RouteSpec+MiddlewareSpecscalafmtAllThe whole test suite compiling unchanged is the load-bearing check here: every legitimate
@@application in this repo passesRequest(or a subtype) asIn1and resolves the instance normally. Only the tuple case, which was never sound, is affected.On "not solvable on 3.x"
@987Nabil noted on 2026-03-22 that this is "not solvable on 3.x", and I want to be explicit about what this PR does and does not claim, since that comment is the reason the issue has sat.
This does not make aspects work on a
Routewith path parameters — that would need the aspect applied after path decoding, which is the breaking change discussed earlier in the thread. What it does is stop the unsound version from compiling, so the failure moves from a runtimeClassCastExceptionin production to a compile error at the call site.That is a source-breaking change for anyone currently writing this: their code compiles today and 500s at runtime. I'd argue that's the right trade for a cast that can never succeed, but it is your call whether it belongs in 3.x or waits for the next major.
If you'd prefer the full fix instead, I'm happy to take a run at applying the aspect after path decoding — please say which you want rather than merging this as a consolation.
Note on scope
I've deliberately kept this to one word of source plus a regression test. Two things I did not do:
IsRequestinstances for tuples. That would make the cast typecheck while still being wrong at runtime.HandlerVersionSpecific.scalain the scala-2/scala-3 dirs. They referenceIsRequest[In1]but need no change; both compile as-is.