Skip to content

fix: guard Handler.@@ with Request <:< In instead of IsRequest (#3141) - #4230

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

fix: guard Handler.@@ with Request <:< In instead of IsRequest (#3141)#4230
DPS0340 wants to merge 2 commits into
zio:mainfrom
DPS0340:fix/3141-guard

Conversation

@DPS0340

@DPS0340 DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown

Fixes #3141. Supersedes #4229, which I'll close once this is up.

/claim #3141

This is @987Nabil's design from #3150, extracted so it applies to current main. Credit for the fix is his; what I did was verify it still works and keep it small.

The bug

Handler.@@ casts the handler's input to Request:

def convert(handler: Handler[R, Err, In, Out]) =
  handler.asInstanceOf[Handler[R, Response, Request, Response]]

On a route with path parameters that input is a tuple — (String, Request) — so the cast fails at runtime:

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

The guard that should have caught this asked for IsRequest[In1]. There is correctly no instance for tuples:

implicitly[Handler.IsRequest[(String, Request)]]   // does not compile

But IsRequest is declared IsRequest[-A], so IsRequest[Request] also conforms to IsRequest[Nothing] — and In1 is only bounded by In1 <: In. The compiler inferred In1 = Nothing, resolved the implicit, and never looked at the tuple.

The fix

   def @@[Env1 <: R, In1 <: In](aspect: HandlerAspect[Env1, Unit])(implicit
-    in: Handler.IsRequest[In1],
+    ev: Request <:< In,

Constraining In — the handler's actual input — rather than In1, which the compiler chooses. There is nothing left to infer around:

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

Four call sites: twice in Handler.scala, once in each HandlerVersionSpecific.scala. IsRequest is deprecated rather than removed, since it's public API.

Why not #3150 as-is

I tried. It merges onto main with one trivial conflict but doesn't compile — #3150 also removed Scope from Handler.apply, and main has evolved that area since. Its diff against today's main is +2062/-12050 across 87 files. This PR is just the guard.

Verification

2.12.21 2.13.18 3.3.7
zioHttpJVM/Test/compile
HandlerAspectSpec 4/4 4/4
HandlerSpec+RouteSpec+RoutesSpec+MiddlewareSpec 113/113
scalafmtCheck clean

Behaviour, 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

Added a typeCheck regression test to HandlerAspectSpec pinning the first row.

The cost, stated plainly

This is source-breaking. Any @@ whose handler input isn't statically Request stops compiling. Two categories:

  1. Tuple inputs — the bug. This code compiles today and throws at runtime.
  2. Nothing inputs, e.g. bare Handler.identity @@ aspect. This works today, because a Nothing-input handler is never invoked with a mismatched value. It will now fail to compile, and the fix is to annotate the handler.

Category 2 is a genuine regression, and it appears in your own docs — handler_aspect.md has val myHandler = Handler.identity. I have not touched that file in this PR; if you take this, that snippet needs a type annotation and I'm happy to add it, but I didn't want to bundle a docs change into the decision.

@987Nabil — your 2024 note said "this breaking change is our best option 😞", and having now measured it I think that was right. But it is your call whether it belongs in 3.x or waits. If the answer is "still not for 3.x", that's a fine answer and I'll close this.

)

Handler.@@ casts the handler's input to Request. On a route with path
parameters that input is a tuple such as (String, Request), and the cast
fails at runtime:

  java.lang.ClassCastException: class zio.http.Request cannot be cast to
  class scala.Tuple2

The guard asked for IsRequest[In1]. IsRequest is contravariant, so
IsRequest[Request] also conforms to IsRequest[Nothing]; since In1 is only
bounded by In1 <: In, the compiler satisfied the implicit by inferring
In1 = Nothing and the tuple was never examined.

Constraining In directly leaves nothing to infer around:

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

This is the guard change from zio#3150 by @987Nabil, extracted so it applies
to current main. IsRequest is deprecated rather than removed, since it is
public API.

This is source-breaking: an aspect applied to a non-Request handler stops
compiling. Such code could never run successfully.
@DPS0340
DPS0340 requested a review from jdegoes as a code owner July 25, 2026 18:00
Copilot AI review requested due to automatic review settings July 25, 2026 18:00
@netlify

netlify Bot commented Jul 25, 2026

Copy link
Copy Markdown

Deploy Preview for zio-http ready!

Name Link
🔨 Latest commit a39765e
🔍 Latest deploy log https://app.netlify.com/projects/zio-http/deploys/6a64fd456d677a00081c696d
😎 Deploy Preview https://deploy-preview-4230--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.

✅ Ready to approve

The change correctly enforces a sound compile-time constraint in the exact locations that performed the unsafe cast, and includes a targeted regression test for the reported failure mode.

Note: this review does not count toward required approvals for merging.

Pull request overview

This PR fixes a soundness hole in Handler.@@ where a handler could be cast to Request input even when its real input is a tuple (e.g. from path parameters), leading to a runtime ClassCastException (Issue #3141). It replaces the ineffective IsRequest[In1] guard with a direct constraint on the handler’s actual input type, so the misuse is rejected at compile time, and keeps IsRequest deprecated for source compatibility.

Changes:

  • Replace the Handler.IsRequest[In1] implicit guard on Handler.@@ with Request <:< In (and update version-specific wrappers accordingly).
  • Deprecate Handler.IsRequest with explanatory scaladoc, retaining it as public API.
  • Add a regression typeCheck test ensuring aspects cannot be applied to tuple-input handlers produced by path parameters.
File summaries
File Description
zio-http/shared/src/main/scala/zio/http/Handler.scala Updates @@ guards to constrain In via Request <:< In and deprecates IsRequest with rationale.
zio-http/shared/src/main/scala-3/zio/http/HandlerVersionSpecific.scala Aligns Scala 3 context-aspect wrapper guard with the new Request <:< In constraint.
zio-http/shared/src/main/scala-2/zio/http/HandlerVersionSpecific.scala Aligns Scala 2 context-aspect wrapper guard with the new Request <:< In constraint.
zio-http/jvm/src/test/scala/zio/http/HandlerAspectSpec.scala Adds a compile-time regression test covering the path-parameter tuple-input case.

Review details

  • Files reviewed: 4/4 changed files
  • Comments generated: 0
  • Review effort level: Low

Note

Your feedback helps us improve the quality of this feature.
Please use 👍 or 👎 to tell us whether this assessment is correct.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…rence

The mdoc snippet used a bare `Handler.identity`, whose `In` inferred to
`Nothing`. That only typechecked through the contravariance hole this PR
closes, so the build fails on it:

  error: handler_aspect.md:435: Cannot prove that zio.http.Request <:< Nothing.

Replaced with a handler that is explicitly `Request => Response`, which is
what the surrounding prose describes. `docs/mdoc` passes.
@DPS0340

DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown
Author

CI caught the judgement call I got wrong in the PR description.

I wrote that I'd deliberately left handler_aspect.md alone so the docs change wouldn't be bundled into the decision. That was the wrong call — the build fails on it:

error: handler_aspect.md:435:1: Cannot prove that zio.http.Request <:< Nothing.
error: handler_aspect.md:437:1: Cannot prove that zio.http.Request <:< Nothing.

docs/mdoc is part of the build, so "not bundled" in practice meant "does not compile". A patch that can't go green can't be reviewed, whatever its merits. Fixed in a39765e: the snippet now declares the handler as Request => Response, which is what the prose around it already describes. docs/mdoc passes locally.

To be clear about what this does and does not change: the regression is still real and still in the PR body. Bare Handler.identity @@ aspect works today and will stop compiling — that is the cost of the fix, and annotating one docs example doesn't make it go away. It just means the repo's own docs no longer depend on the unsound path.

Current state, all local:

2.12.21 2.13.18 3.3.7
zioHttpJVM/Test/compile
HandlerAspectSpec 4/4 4/4
Handler+Route+Routes+Middleware 113/113
docs/mdoc
scalafmtCheck clean

@DPS0340

DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown
Author

Reporting against my own PR: this breaks binary compatibility, and my earlier one did not.

I ran mimaReportBinaryIssues locally after seeing the CI check go red:

[error] zio-http: Failed binary compatibility check against dev.zio:zio-http_2.13:3.11.3!
[error]  * method @@(HandlerAspect, Handler#IsRequest, <:<, <:<)Handler
[error]    in interface zio.http.Handler does not have a correspondent in current version
[error]  * method @@(HandlerAspect, Handler#IsRequest, <:<, <:<, Object, Tag)Handler
[error]    in interface zio.http.Handler does not have a correspondent in current version

The implicit parameter is part of the erased signature, so swapping IsRequest[In1] for Request <:< In changes the method's shape. Both @@ overloads move.

This is a real difference between the two approaches, and it goes against the one I argued for:

#4229 (withdrawn) #4230 (this)
change IsRequest[-A]IsRequest[A] IsRequest[In1]Request <:< In
Mima passes (variance is not in the erased signature) fails, 2 problems
error quality poor good
source-breaking yes yes

So the honest scoreboard is that neither patch is strictly better. Mine was binary-compatible with a bad error message; this one has the right design and the right diagnostic but needs a binary-compatibility exception.

What I'm not going to do is add the filter myself. MimaSettings.scala already carries 43 exclusions, so there's precedent, and the two lines Mima suggests would make this green:

ProblemFilters.exclude[DirectMissingMethodProblem]("zio.http.Handler.@@")

But adding an exclusion is a maintainer's call about what breakage is acceptable in a 3.x line — it's the same decision as whether this belongs in 3.x at all, just expressed in a config file. Me quietly adding it to get a green check would be hiding the cost inside the patch that causes it.

Three ways forward, your pick:

  1. Add the filter — I'll push it if you say so, with a comment explaining what it covers.
  2. Hold for the next major, where a binary break needs no exception. Given @987Nabil's 2024 note that this is "our best option 😞", this may be where it belongs.
  3. Take fix: make IsRequest invariant so aspects on path-param routes fail to compile (#3141) #4229's approach instead — binary-compatible, but the error message is could not find implicit value for parameter in: Handler.IsRequest[In1], which tells a user nothing. I closed it, and can reopen.

Everything else is green: build (including docs/mdoc) passes, and locally Test/compile is clean on 2.12.21 / 2.13.18 / 3.3.7 with 113/113 on the handler and routing suites.

@DPS0340

DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown
Author

The other red check is not related to this PR either.

Build and Test (3.3.7, temurin@25) fails on zio.http.DualSSLSpec, which fails identically on PRs touching nothing nearby — I checked #4225 (Avoid redundant String round-trip in Body) and #4227 (Update parsers to 4.17.3) earlier today and both show the same spec failing in the same job.

So the state of this PR is:

check result
build (compile + docs/mdoc)
Mima Check ❌ — mine, needs a decision (previous comment)
Build and Test (3.3.7, temurin@25) ❌ — DualSSLSpec, pre-existing on main
everything else green or skipped

Only the Mima one needs anything from me, and I've laid out the three options above rather than pick one.

Separately, DualSSLSpec failing across unrelated PRs might be worth a look — it blocks the matrix on everything, since the other eight jobs cancel as soon as it goes red.

@DPS0340

DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown
Author

I went looking for a fourth option — a fix that keeps the good error message and stays binary-compatible — so the Mima decision wouldn't be forced on you. There isn't one. Reporting the negative result rather than leaving you to wonder.

The idea was to keep IsRequest in the signature (preserving erasure, so Mima passes) but ask it about In instead of In1. Three shapes, all dead ends:

1. IsRequest[In], invariant

error: contravariant type In occurs in invariant position
       in type Handler.IsRequest[In] of value in

2. IsRequest[In], contravariant

error: contravariant type In occurs in covariant position
       in type Handler.IsRequest[In] of value in

In is contravariant in Handler[-R, +Err, -In, +Out], so it is only legal in a contravariant position. A type constructor's parameter can be invariant, covariant, or contravariant — and all three are rejected here. <:< works because Request <:< In puts In in <:<'s second, covariant slot, which is where a contravariant type is allowed to appear.

So the erased signature has to change. The two-line Mima filter is unavoidable if you want the better diagnostic.

3. Along the way I also found why IsRequest[In] wouldn't have worked even if variance allowed it. Handler.error applies an aspect to a handler whose input is Any:

def error(...): Handler[Any, Nothing, Any, Response] =
  (fromResponse(Response.status(status)) @@ Middleware.interceptHandlerStateful(...))

That is sound — a handler accepting Any accepts a Request — and Request <:< Any is provable, so the current PR compiles it. IsRequest[Any] does not exist, so any IsRequest-based guard would need a second instance added by hand. That is a real point in <:<'s favour I hadn't articulated: it gets Any, Request, and any supertype right for free, where a marker trait needs an instance per case.

Updated scoreboard, with nothing left to invent:

#4229 (closed) #4230 (this)
Mima passes fails — filter required, no way around it
error message names an internal marker trait names the actual type mismatch
handles In = Any yes (accidentally, via Nothing) yes (correctly, via <:<)

The three options from my earlier comment stand, minus the hope that a fourth existed. I still haven't added the filter — that call is yours.

@DPS0340

DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown
Author

Closing the loop on CI so the state is unambiguous. Three checks are red; one is mine and two are not, and I verified that rather than asserting it.

check result evidence
build (compile + docs/mdoc) ✅ SUCCESS
Mima Check mine 2 DirectMissingMethodProblem on Handler.@@; unavoidable, see below
Build and Test (3.3.7, temurin@25) ❌ pre-existing zio.http.DualSSLSpec, fails identically on #4225 and #4227
Unsafe Scoverage (2.13.18) ❌ pre-existing also FAILURE on #4225 and #4227

Both pre-existing failures reproduce on PRs that change unrelated things — #4225 is a Body string round-trip and #4227 is a dependency bump. Neither touches handler aspects.

On the Mima failure, I checked whether it was avoidable and it is not. The idea was to keep IsRequest in the signature — preserving erasure — but ask it about In instead of In1. Both variances are rejected by the compiler:

IsRequest[In] invariant     → contravariant type In occurs in invariant position
IsRequest[In] contravariant → contravariant type In occurs in covariant position

In is contravariant in Handler[-R, +Err, -In, +Out], so it can only appear in a contravariant position. Request <:< In works because it lands In in <:<'s second (covariant) slot. There is no third shape to try — the erased signature has to change.

So the decision I can't make for you is unchanged, and now provably minimal:

  1. Add the two-line ProblemFilters.exclude[DirectMissingMethodProblem]("zio.http.Handler.@@")MimaSettings.scala already carries 43 exclusions.
  2. Hold for the next major.
  3. Reopen fix: make IsRequest invariant so aspects on path-param routes fail to compile (#3141) #4229 (binary-compatible, but the error message names an internal marker trait instead of the real mismatch).

Also worth flagging separately: DualSSLSpec red on main blocks the whole matrix on every PR, since the other eight jobs cancel as soon as it fails.

@987Nabil

Copy link
Copy Markdown
Contributor

This issue is not fixable on 3.x

@987Nabil 987Nabil closed this Sep 11, 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.

4 participants