Skip to content

fix: make IsRequest invariant so aspects on path-param routes fail to compile (#3141) - #4229

Closed
DPS0340 wants to merge 2 commits into
zio:mainfrom
DPS0340:fix/3141-isrequest-invariance
Closed

DPS0340 wants to merge 2 commits into
zio:mainfrom
DPS0340:fix/3141-isrequest-invariance

Conversation

@DPS0340

@DPS0340 DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown

Fixes #3141.

/claim #3141

Root cause

Handler.@@ casts the handler's input to Request, and requires an IsRequest[In1] to justify that cast:

def @@[Env1 <: R, In1 <: In](aspect: HandlerAspect[Env1, Unit])(implicit
  in: Handler.IsRequest[In1],
  ...
) = {
  def convert(handler: Handler[R, Err, In, Out]) =
    handler.asInstanceOf[Handler[R, Response, Request, Response]]
  aspect.applyHandler(convert(self))
}

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 no IsRequest instance for tuples. I confirmed that directly against 3.11.2:

implicitly[Handler.IsRequest[(String, Request)]]
// error: could not find implicit value for parameter e:
//        zio.http.Handler.IsRequest[(String, zio.http.Request)]

So the guard is correct — it just wasn't being asked about the right type. IsRequest was contravariant:

sealed trait IsRequest[-A]
implicit val request: IsRequest[Request] = ...

Contravariance means IsRequest[Request] <: IsRequest[Nothing], and this compiles:

implicitly[Handler.IsRequest[Nothing]]  // succeeds

In1 is only bounded by In1 <: In, and Nothing satisfies any such bound. So on a tuple-input handler the compiler infers In1 = Nothing rather than (String, Request), the implicit resolves, and the asInstanceOf goes through unchecked — surfacing at runtime as:

java.lang.ClassCastException: class zio.http.Request cannot be cast to class scala.Tuple2
	at zio.http.Handler$FromFunctionZIO$$anon$20.apply(Handler.scala:1489)
	at zio.http.Handler.$anonfun$$at$at$2(Handler.scala:60)

The fix

-  sealed trait IsRequest[-A]
+  sealed trait IsRequest[A]

With IsRequest invariant, 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:

< HTTP/1.1 500 Internal Server Error
ClassCastException: class zio.http.Request cannot be cast to class scala.Tuple2

Same file against this branch (publishLocal):

[error] Reproducer.scala:22:8: could not find implicit value for parameter in:
[error]                        zio.http.Handler.IsRequest[In1]
[error]     }) @@ maybeWebSession

Locally, on both Scala versions:

2.13.18 3.3.7
zioHttpJVM/Test/compile
HandlerAspectSpec 4 passed, 0 failed 4 passed, 0 failed
HandlerSpec + RoutesSpec + RouteSpec + MiddlewareSpec 110 passed, 0 failed
scalafmtAll clean

The whole test suite compiling unchanged is the load-bearing check here: every legitimate @@ application in this repo passes Request (or a subtype) as In1 and 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 Route with 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 runtime ClassCastException in 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:

  • Add IsRequest instances for tuples. That would make the cast typecheck while still being wrong at runtime.
  • Touch HandlerVersionSpecific.scala in the scala-2/scala-3 dirs. They reference IsRequest[In1] but need no change; both compile as-is.

… 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.
Copilot AI review requested due to automatic review settings July 25, 2026 16:40
@netlify

netlify Bot commented Jul 25, 2026

Copy link
Copy Markdown

Deploy Preview for zio-http ready!

Name Link
🔨 Latest commit cde4caa
🔍 Latest deploy log https://app.netlify.com/projects/zio-http/deploys/6a64eb5b5101b90008050271
😎 Deploy Preview https://deploy-preview-4229--zio-http.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@CLAassistant

CLAassistant commented Jul 25, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.IsRequest variance from contravariant to invariant to prevent In1 = Nothing fallback 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.
@DPS0340

DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown
Author

CI caught something that makes this PR's case both stronger and more expensive, so reporting it against myself.

The build failed on docs/mdoc — not on any test, but on the documentation:

error: handler_aspect.md:435:1: could not find implicit value for parameter in: zio.http.Handler.IsRequest[In1]
error: handler_aspect.md:437:1: could not find implicit value for parameter in: zio.http.Handler.IsRequest[In1]

The snippet is:

val myHandler = Handler.identity      // In infers to Nothing

myHandler @@ HandlerAspect.fail(Response.forbidden("Access Denied!"))

Handler.identity[A] with A unspecified infers Handler[Any, Nothing, Nothing, Nothing] — I confirmed this against 3.11.2 directly. So the documented example was compiling only through the contravariance hole this PR closes: it resolved IsRequest[Nothing], not IsRequest[Request].

Two honest readings of that:

  1. It strengthens the diagnosis. The Nothing inference is not a corner case I constructed — it is load-bearing in the project's own documentation. That is what an unsound implicit looks like when it has been in place long enough.
  2. It raises the cost. This is real evidence the change is source-breaking beyond the buggy case. Handler.identity @@ aspect reads as reasonable and does not hit the ClassCastException at runtime, because Nothing-input handlers are never actually invoked with a mismatched value. So this PR breaks some code that works today, not only code that 500s today.

I fixed the doc by giving the example the type its surrounding prose already describes — Request => Response — and docs/mdoc now passes locally.

But I do not want to hide the trade behind a green build. The precise scope is: any @@ application whose handler input is not statically Request stops compiling. For tuple inputs that is the bug being fixed. For Nothing inputs it is a genuine, if unusual, regression, and the fix is for the user to annotate the handler.

If that is too wide for a 3.x line, the narrower alternative is to leave IsRequest contravariant and add an explicit IsRequest[Nothing]-blocking ambiguity — I can do that instead if you prefer keeping Handler.identity @@ aspect working. It is uglier, and it does not communicate the invariant, which is why I did not lead with it.

Updated verification, all local:

2.12.21 2.13.18 3.3.7
zioHttpJVM/Test/compile
HandlerAspectSpec 4/4 4/4
Handler+Routes+Route+Middleware 110/110
docs/mdoc
Mima binary compatibility ✅ (CI)

@DPS0340

DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown
Author

CI is green on everything my change affects. The one remaining failure is pre-existing and I can show that rather than assert it.

docs/mdoc now passes — that was the real failure, and it was mine. Fixed in cde4caa.

The remaining red is DualSSLSpec, and it fails identically on PRs that touch nothing related:

PR changes Build and Test (3.3.7) failing spec
#4225 Avoid redundant String round-trip in Body FAILURE zio.http.DualSSLSpec
#4227 Update parsers to 4.17.3 FAILURE zio.http.DualSSLSpec
#4229 (this) IsRequest variance FAILURE zio.http.DualSSLSpec

Same spec, same job, same 8 CANCELLED / 1 FAILURE shape — the matrix cancels siblings once one fails, which is why only one shows red. The stack trace is in TLS client setup (NettyClientDriver.<clinit>ClientHttpsSpecBase), nowhere near handler aspects or implicit resolution.

Unsafe Scoverage is also failing on all three.

Checks that are green and do cover this change:

  • Mima Check — SUCCESS. The variance change is binary-compatible.
  • docs/mdoc — passes locally after the doc fix.

And locally, across every Scala version in the CI matrix:

2.12.21 2.13.18 3.3.7
zioHttpJVM/Test/compile
HandlerAspectSpec 4/4 4/4
HandlerSpec+RoutesSpec+RouteSpec+MiddlewareSpec 110/110

I'd rather not re-push to chase a red that isn't mine, but say the word if you want it rebased once DualSSLSpec is sorted.

@DPS0340

DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown
Author

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:

The actual bug was, that the handler aspect could be applied to a handler that has a input different then request. There was a construct that should have prevented it, but it did not work.

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 IsRequest invariant, which removes IsRequest[Nothing] and so blocks the inference. But it still constrains In1, a type the compiler is free to pick. Yours constrains In itself — the handler's actual input — so there is nothing to infer around. I verified the escape is closed at the source:

implicitly[Request <:< Nothing]
// Cannot prove that zio.http.Request <:< Nothing.

It also explains the regression I reported against myself earlier in this thread. Handler.identity @@ aspect breaks under my patch because In infers to Nothing and IsRequest[Nothing] no longer exists. Under Request <:< In it fails for the right reason — the handler genuinely does not accept a Request — and the error message says so, instead of pointing at a missing implicit for an internal marker trait.

Your 2024 assessment of the cost was also more accurate than mine:

This is not binary compatible. In fact it would make code not compile that compiled before. But that code would be impossible to run anyway.

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 @@ on Route and new RouteCompanionVersionSpecific files — a bigger surface than the guard fix needs. If the size is what stalled it, the guard change alone is two lines and I'm happy to open that, or to rebase #3150 as-is, whichever you prefer.

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.

@DPS0340

DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown
Author

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 work

I fetched it and merged onto current main. Only one conflict (RouteSpec.scala, two suites added at the same spot — trivially resolved), but the result does not compile:

HandlerAspect.scala:128: type mismatch;
 found   : Handler[Scope & Env, Response, Any, Response]
 required: Handler[Env1, Response, Request, Response]

#3150 also removed Scope from Handler.apply, and main has since evolved that area. Its diff against today's main is +2062/-12050 across 87 files — reviving the branch means reverting two years of unrelated work. That is not a rebase, and I would not ask you to review it.

Extracting just the guard change does work

The 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 Handler.scala, once in each HandlerVersionSpecific.scala)

zioHttpJVM/Test/compile passes on 2.13.18 with no other change. HandlerAspectSpec + RouteSpec + RoutesSpec + MiddlewareSpec: 49 passed, 0 failed.

Behaviour, verified against a locally published build:

case result
the #3141 reproducer Cannot prove that Request <:< (String, Request)
Handler.fromFunction[Request](_ => Response.ok) @@ aspect compiles
bare Handler.identity @@ aspect Cannot prove that Request <:< Nothing

Being straight about the last row

The docs snippet breaks under both approaches — mine and yours. I said earlier that Request <:< In would fix it, and having now run it, that was wrong. Handler.identity with no type argument infers In = Nothing, and no correct guard can accept 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 suggest

Close #4229 and take the four-line guard change instead. I can open it as a fresh PR against main, credited to you since it is your design from #3150 — say the word and I'll push it. Or if you'd rather own it, the diff is above and it's yours.

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.

@DPS0340

DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown
Author

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:

this PR (#4229) #4230
change IsRequest[-A]IsRequest[A] IsRequest[In1]Request <:< In
constrains In1, which the compiler infers In, the handler's actual input
error on the bug could not find implicit value for parameter in: Handler.IsRequest[In1] Cannot prove that Request <:< (String, Request)

Both close the Nothing hole and both are source-breaking in the same two places. The difference is the diagnostic: mine points at a missing instance for an internal marker trait, which tells a user nothing about what they did wrong. For a change that breaks compilation, the message is most of the cost.

Thanks for the patience with the back-and-forth on this one.

@DPS0340 DPS0340 closed this Jul 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ClassCassException when using HandlerAspect and Path parameters.

3 participants