fix: handle exceptions inside the middleware pipeline - #510
Merged
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On 0.3 the exception handler runs in
Kernel::onRequest(), outside the middlewarepipeline:
Hypervel\Dispatcher\Pipeline::carry()overrides Laravel'scarry()but, unlikeIlluminate\Routing\Pipeline, has notry/catchand nohandleException().Hyperf\Pipeline\Pipelinehas neither either, so nothing in the chain turns athrowable into a response before it leaves the pipeline. A throwable propagates
past every middleware frame, and no middleware can ever see the status the
client actually receives.
Impact
Hyperf\Metric\Middleware\MetricMiddlewareis the clearest case —request_statusstays at its
'500'default for anything that is not a HyperfHttpException.ModelNotFoundExceptionis exactly that case:Handler::prepareException()maps itto
NotFoundHttpException, so the client gets a 404 while the metric records a 500.Every 404 in an application that uses
findOrFail()is counted as a server error,which makes error-rate alerting on
request_statusunusable.The same blindness affects any middleware that inspects the response — request
logging, timing by status, error-path header handling.
This is already fixed on 0.4 by
Hypervel\Routing\Pipeline(52bfbf7ed) plus986f02b1f. Since 0.4 is not released, this backports that behaviour to the 0.3architecture.
Solution
Mirrors the 0.4 split: a neutral hook in
hypervel/dispatcher, the policy inhypervel/foundation. The subclass has to live in foundation becausehypervel/dispatcheronly depends onhyperf/context,hyperf/dispatcherandhyperf/pipeline— adding the exception-handler contract there would invert thelayering.
1.
Hypervel\Dispatcher\Pipeline— add the hookWraps the slice body in
try/catchand adds aprepareDestination()override(
Hyperf\Pipeline\Pipelinedoes not have one, so exceptions from the pipeline'sdestination would otherwise be missed), both delegating to:
Self-contained, no new dependencies, no behaviour change on its own — the
default rethrows, exactly as before.
2.
Hypervel\Foundation\Http\Pipeline— new subclassOverrides
handleException()toreport()+render()throughExceptionHandler,porting the final 0.4 semantics including the in-flight idiom:
PHP appends the in-flight throwable to the
previouschain of anything raisedinside
finally, and thereturnsuppresses it once a response exists. So afailure inside
report()/render()carries the original as itspreviousinsteadof discarding the root cause, and a same-object rethrow stays cycle-free.
Two 0.3-specific deltas from 0.4:
Hypervel\Http\Request.HttpRequestHandler::handle()sends a
Hyperf\HttpMessage\Server\Request, so 0.4's! $passable instanceof Requestguard would rethrow on every request and make the backport a silent no-op. The
request is resolved from the container instead, as
Handler::handle()alreadydoes. This is safe because
CoreMiddleware::dispatch()callsRequestContext::set($request)before the pipeline runs.withoutDuplicatesdefaults tofalse, so double reporting would be visible.It does not occur: once the pipeline returns a response,
Kernel::onRequest()'scatchno longer fires for pipeline throwables. Verified by counting log output —the exception is reported exactly once.
3. HTTP-only gate
Not covered in the issue report, but found while tracing: on 0.3 the pipeline is
container-resolved by
Hyperf\Dispatcher\HttpRequestHandler, which is shared by theHTTP kernel and
WebsocketKernel::onHandShake(). That kernel relies on catchingthe throwable itself to run
FdCollector::del($fd)/WsContext::release($fd).Swallowing globally would leak an fd and a context on every failed handshake.
So
handleException()only engages when the passable carries aHypervel\Http\DispatchedRouteattribute — set exclusively byHypervel\Http\CoreMiddleware::dispatch(). The WebSocket path attaches Hyperf'splain
Dispatched, so handshake behaviour and fd cleanup are untouched.4.
Kernel::onRequest()left aloneIts outer
catchstill guards the pre-pipeline work (initRequestAndResponse, URItrimming, uploaded-file conversion,
coreMiddleware->dispatch()) and the non-gatedpaths.
Files changed
src/dispatcher/src/Pipeline.phptry/catchincarry(),prepareDestination()override,handleException()hook that rethrowssrc/foundation/src/Http/Pipeline.phpExceptionHandler, gated onDispatchedRoutesrc/foundation/src/ConfigProvider.phpDispatcher\Pipeline::class => Foundation\Http\Pipeline::classsrc/foundation/composer.jsonhypervel/dispatcher: ^0.3tests/Dispatcher/PipelineTest.phptests/Foundation/Http/PipelineTest.phptests/Testbench/PipelineExceptionHandlingTest.phpBehaviour changes
Middleware that currently catch domain exceptions will stop seeing them — by the
time an exception reaches a middleware frame it is already a response. This is
Laravel's behaviour, and 0.4 already carries the same change. Applications relying
on catching exceptions in middleware should inspect the response status instead.
Two consequences worth calling out in the release notes:
EnsureFrontendRequestsAreStatefultype-hintsPipelinein itsconstructor, so its nested pipeline now resolves to the subclass. Verified: the
final HTTP status is identical before and after (500 → 500); what changes is that
inner exceptions arrive at outer middleware as a response rather than a throwable.
This is intended — the nested pipeline runs the same HTTP request through real HTTP
middleware, and leaving it out would create an inconsistent boundary. Code wrapping
sanctum.middlewareto catch inner exceptions would need updating.$throwableinKernel::onRequest()'sfinallyisnow
nullfor handled pipeline exceptions, soRequestHandled/RequestTerminatedcarry
exception: nullalongside a real error response. Matches 0.4 and Laravel;Telescope\Watchers\RequestWatcheralready keys off$event->response->getStatusCode(), so this improves rather than regresses.Unaffected:
Hypervel\Support\PipelineextendsHyperf\Pipeline\Pipeline, notDispatcher\Pipeline, so bus, queue and api-client are not on this inheritancechain.
Testing
scout/. Confirmed pre-existing by stashing thechanges and re-running — identical output. No new suppressions beyond the two
finally.exitPointignores the idiom requires.correctly still passes — it asserts non-HTTP passables rethrow). Removing the
container binding fails the end-to-end test with exactly the reported symptom:
client gets 404, middleware observes
null.ModelNotFoundExceptionreturns 404 and the wrapping middleware observes 404.