I'm preparing an RFC for error handling in Expressive 1.1, and discovered some potential problems with how error handling works for the upcoming 1.3 release of Stratigility.
While the ErrorHandler
class is fantastic, it provides one fundamental problem: the try/catch block will have no effect out-of-the-box currently.
The reason is because Dispatch
already has a try/catch block, and, when a Throwable or Exception is caught, it calls on the next error middleware, passing the Throwable or Exception:
return $next($request, $response, $exception);
In turn, this will now raise a deprecation notice, as the third argument to $next()
is deprecated.
To make this work, I see the following potential paths.
Flag
First, we could introduce a flag for MiddlewarePipe
, raiseExceptions
. When enabled, the flag will be passed to the constructor of Next
, which will in turn pass it on to Dispatch
. Internally, if that flag is enabled in Dispatch
, it will either not wrap middleware dispatch in a try/catch block, or re-raise the caught exception. Applications can then opt-in to this behavior in the 1.3 series; it would only technically be needed for the outermost middleware, though composed middleware pipelines should likely opt-in as well.
This flag would be marked deprecated from the outset, and removed for version 2.0.
Problems with the approach:
- Requires intervention in order to enable the flag.
- Depending on how the flag is enabled, there may be breakage when moving to 2.0. For example, if using a setter method, removing that method in 2.0 would lead to a BC break for users. If using a constructor argument, no issue would be present, but detecting the constructor argument and notifying users later to update and remove it would pose problems.
Error middleware
Alternately, we could introduce a special error middleware. This could compose an ErrorGenerator
and potentially error handler listeners, and then be injected into the middleware pipeline, as the last error middleware. When invoked, it would delegate to the error generator and, if incorporating listeners, trigger them.
The problems with the approach are:
- It requires changing existing pipelines to add the middleware.
- Any triggering of the middleware will de facto result in a deprecation notice, as it is error middleware.
- Users will need to remove the middleware before upgrading to 2.0, as that version does not allow error middleware.
Special "Final Handler"
When prototyping the error handler ideas for my own website originally, I created a special "Final Handler" that wrapped my error handler, and injected this into the application (instead of the no-op final handler), while simultaneously injecting the error handler as middleware. This approach meant that I was able to update from 1.3 to 2.0 with no issues. During 1.3 usage, the final handler was triggered for errors (as it received the $error
argument due to Dispatch
triggering an error middleware, but no error middleware being present), while in 2.0, the final handler would only receive the request/response pair, and return the response verbatim.
The problems with this approach:
- It requires changing existing pipelines to add the
ErrorHandler
middleware in the outer layer. (This is true anyways.)
- It requires swapping
FinalHandler
implementations for 1.3. (Potentially also true anyways.)
- When upgrading to 2.0, even though things continue to "just work", users would need to remember to switch final handlers again, and/or remember to remove the error handler argument when creating the final handler. (In other words, changes are necessary over multiple versions.).
Alternate pipeline implementation
Another approach is to introduce alternate versions of:
Dispatch
; implementation would be identical except that it would not wrap middleware dispatch in a try/catch
block.
Next
; implementation would be essentially identical except for the version of Dispatch
used. Additionally, if a $err
argument is provided:
- if it is a
Throwable
/Exception
, it would re-raise it.
- if a string, it would raise an exception with the string as a message
- for other non-null values, it would raise an exception indicating an unknown error occurred.
MiddlewarePipe
; implementation would be identical, except for the version of Next
used.
In version 2.0, the original versions would be used (where appropriate; Dispatch
goes away in version 2 currently), and these alternate implementations would simply extend those with the original names. The alternate Next
implementation would remove the special $err
handling, as it would no longer be relevant.
This would provide an opt-in approach. Users would update their code to use the new MiddlewarePipe
variant, and the code would continue to work with version 2.0. After updating to 2.0, they could update to use the original MiddlewarePipe
, but could continue to use the alternate class as well, though we would likely deprecate it with the 2.0 release.
Those that do not update would need to make changes to their pipeline on upgrading to version 2.
Problems associated with this path:
- We'd be introducing new classes for the 1.X series that we'd be deprecating with the 2.X series. We would recommend updating code twice, once to adopt the new pipeline features, and once on upgrade to 2.0 to use the originals again.
- It requires changing existing pipelines to add the
ErrorHandler
middleware in the outer layer. (This is true anyways.)
- It recommends swapping
FinalHandler
implementations (to NoopFinalHandler
) for 1.3. (True anyways.)
Combine flag with alternate implementation strategy
Alternately, we could combine the ideas of the flag with the alternate implementations: users would opt-in by providing a flag to the MiddlewarePipe
, and this would then vary behavior in the Next
and Dispatch
classes:
- When enabled,
Next
, on receiving an $err
argument, would raise an exception (as noted above).
- When enabled,
Dispatch
would either re-raise exceptions or omit the try/catch
block entirely.
We could do this similarly to the response prototype, by providing a setter; in 2.0, it would be a no-op, and potentially raise a deprecation notice. Users would then opt-in to the new error handling, and, if done in the outermost layer, the new error handling would apply for the entire application.
Problems with this course of action:
- It requires changing existing pipelines to add the
ErrorHandler
middleware in the outer layer. (This is true anyways.)
- It requires swapping the
FinalHandler
implementation for the NoopFinalHandler
for 1.3. (Potentially also true anyways.)
- When upgrading to 2.0, even though things continue to "just work", users would need to remember to remove the call to set the flag; this could possibly be prompted by a deprecation notice, however.
Suggested course
My suggestion is to go with the last option, using a flag to alter the behavior of the existing Dispatch
and Next
implementations. This introduces no backwards compatibility breaks, and can be adopted at the outer-most application layer to introduce the new error handling functionality. This would make implementation in Expressive far easier as well, as that project could inject the flag and add the error middleware automatically in an immediate minor release with no BC breaks; a later major release could remove the flag invocation.
Usage would then become:
$pipeline = new MiddlewarePipe();
$pipeline->raiseExceptionsByDefault();
$pipeline->setResponsePrototype(new Response());
$pipeline->pipe(ErrorHandler::class());
$pipeline->pipe(/* ... */);
// etc. ...
$pipeline->pipe(NotFoundHandler::class());
$pipeline($request, $response, new NoopFinalHandler());