Skip to content

fix: handle exceptions inside the middleware pipeline - #510

Merged
albertcht merged 3 commits into
0.3from
hotfix/fix-middleware-error
Aug 14, 2026
Merged

fix: handle exceptions inside the middleware pipeline#510
albertcht merged 3 commits into
0.3from
hotfix/fix-middleware-error

Conversation

@albertcht

@albertcht albertcht commented Aug 14, 2026

Copy link
Copy Markdown
Member

Problem

On 0.3 the exception handler runs in Kernel::onRequest(), outside the middleware
pipeline:

$response = $this->dispatcher->dispatch(
    $request,
    $this->getMiddlewareForRequest($request),
    $this->coreMiddleware
);
} catch (Throwable $throwable) {
    $response = $this->getResponseForException($throwable);   // outside the pipeline
}

Hypervel\Dispatcher\Pipeline::carry() overrides Laravel's carry() but, unlike
Illuminate\Routing\Pipeline, has no try/catch and no handleException().
Hyperf\Pipeline\Pipeline has neither either, so nothing in the chain turns a
throwable 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\MetricMiddleware is the clearest case — request_status
stays at its '500' default for anything that is not a Hyperf HttpException.
ModelNotFoundException is exactly that case: Handler::prepareException() maps it
to 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_status unusable.

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) plus
986f02b1f. Since 0.4 is not released, this backports that behaviour to the 0.3
architecture.

Solution

Mirrors the 0.4 split: a neutral hook in hypervel/dispatcher, the policy in
hypervel/foundation. The subclass has to live in foundation because
hypervel/dispatcher only depends on hyperf/context, hyperf/dispatcher and
hyperf/pipeline — adding the exception-handler contract there would invert the
layering.

1. Hypervel\Dispatcher\Pipeline — add the hook

Wraps the slice body in try/catch and adds a prepareDestination() override
(Hyperf\Pipeline\Pipeline does not have one, so exceptions from the pipeline's
destination would otherwise be missed), both delegating to:

protected function handleException(mixed $passable, Throwable $e): mixed
{
    throw $e;
}

Self-contained, no new dependencies, no behaviour change on its own — the
default rethrows, exactly as before.

2. Hypervel\Foundation\Http\Pipeline — new subclass

Overrides handleException() to report() + render() through ExceptionHandler,
porting the final 0.4 semantics including the in-flight idiom:

try {
    throw $e;
} finally {
    $handler->report($e);
    return $this->handleCarry($handler->render($request, $e));
}

PHP appends the in-flight throwable to the previous chain of anything raised
inside finally, and the return suppresses it once a response exists. So a
failure inside report()/render() carries the original as its previous instead
of discarding the root cause, and a same-object rethrow stays cycle-free.

Two 0.3-specific deltas from 0.4:

  • The passable is not a Hypervel\Http\Request. HttpRequestHandler::handle()
    sends a Hyperf\HttpMessage\Server\Request, so 0.4's ! $passable instanceof Request
    guard would rethrow on every request and make the backport a silent no-op. The
    request is resolved from the container instead, as Handler::handle() already
    does. This is safe because CoreMiddleware::dispatch() calls
    RequestContext::set($request) before the pipeline runs.
  • withoutDuplicates defaults to false, so double reporting would be visible.
    It does not occur: once the pipeline returns a response, Kernel::onRequest()'s
    catch no 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 the
HTTP kernel and WebsocketKernel::onHandShake(). That kernel relies on catching
the 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 a
Hypervel\Http\DispatchedRoute attribute — set exclusively by
Hypervel\Http\CoreMiddleware::dispatch(). The WebSocket path attaches Hyperf's
plain Dispatched, so handshake behaviour and fd cleanup are untouched.

4. Kernel::onRequest() left alone

Its outer catch still guards the pre-pipeline work (initRequestAndResponse, URI
trimming, uploaded-file conversion, coreMiddleware->dispatch()) and the non-gated
paths.

Files changed

File Change
src/dispatcher/src/Pipeline.php try/catch in carry(), prepareDestination() override, handleException() hook that rethrows
src/foundation/src/Http/Pipeline.php new — reports + renders via ExceptionHandler, gated on DispatchedRoute
src/foundation/src/ConfigProvider.php bind Dispatcher\Pipeline::class => Foundation\Http\Pipeline::class
src/foundation/composer.json add hypervel/dispatcher: ^0.3
tests/Dispatcher/PipelineTest.php +4 tests (hook semantics, default rethrow)
tests/Foundation/Http/PipelineTest.php new — 8 tests, 0.4 exception-chain matrix + 0.3 cases
tests/Testbench/PipelineExceptionHandlingTest.php new — 3 end-to-end regression tests

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

  • Sanctum. EnsureFrontendRequestsAreStateful type-hints Pipeline in its
    constructor, 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.middleware to catch inner exceptions would need updating.
  • Request lifecycle events. $throwable in Kernel::onRequest()'s finally is
    now null for handled pipeline exceptions, so RequestHandled/RequestTerminated
    carry exception: null alongside a real error response. Matches 0.4 and Laravel;
    Telescope\Watchers\RequestWatcher already keys off
    $event->response->getStatusCode(), so this improves rather than regresses.

Unaffected: Hypervel\Support\Pipeline extends Hyperf\Pipeline\Pipeline, not
Dispatcher\Pipeline, so bus, queue and api-client are not on this inheritance
chain.

Testing

  • Full suite: 4666 tests pass.
  • PHPStan: 2 errors, both in scout/. Confirmed pre-existing by stashing the
    changes and re-running — identical output. No new suppressions beyond the two
    finally.exitPoint ignores the idiom requires.
  • php-cs-fixer: clean on all changed files.
  • Mutation-checked. Disabling the handler fails 7 of 8 foundation tests (the 8th
    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.
  • End-to-end through the real kernel dispatch path: a route throwing
    ModelNotFoundException returns 404 and the wrapping middleware observes 404.

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b0032ef4-0048-42f9-9b02-a05ee403cbc5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@albertcht
albertcht merged commit 67bdbf1 into 0.3 Aug 14, 2026
17 checks passed
@albertcht
albertcht deleted the hotfix/fix-middleware-error branch August 14, 2026 06:11
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.

1 participant