Problem/Motivation
When requesting ?_format=json
on a route that does not support it, we get a plain text response. If we also specify Accept: application/json
, then we suddenly do get a JSON response. Why? #2481453: Implement query parameter based content negotiation as alternative to extensions was supposed to remove the support for Accept
header negotiation, so it should have no effect at all, right?
Well, turns out that Symfony itself still does some basic content negotiation that causes Accept
to work for error responses.
Clearly, this is very confusing and bad DX.
The root cause is:
- when the default Drupal core exception subscriber is used (
\Drupal\Core\EventSubscriber\DefaultExceptionSubscriber
), not to be confused with the default serialization exception subscriber (\Drupal\serialization\EventSubscriber\DefaultExceptionSubscriber
) nor the default HTML exception subscriber (\Drupal\Core\EventSubscriber\DefaultExceptionHtmlSubscriber
), then… - …
\Drupal\Core\EventSubscriber\DefaultExceptionSubscriber::onException()
is what handles the exception and - it does
$format = $this->getFormat($event->getRequest());
to determine in which format to format the error response - this calls
\Symfony\Component\HttpFoundation\Request::getAcceptableContentTypes()
, which is the code that inspects theAccept
subscriber
Discovered at #2737719: EntityResource: Provide comprehensive test coverage: for every entity type, every format, every method.
Proposed resolution
Remove
Remaining tasks
None.
User interface changes
None.
API changes
None.
Data model changes
None.
Comment | File | Size | Author |
---|---|---|---|
#38 | 2825347-38.patch | 14.33 KB | Wim Leers |
#16 | 2825347-16.patch | 4.91 KB | Wim Leers |
Comments
Comment #2
damiankloip CreditAttribution: damiankloip at Acquia commentedI tried to reproduce this. I can't anymore. I think we may have fixed it with all the other improvements we've made to error handling? To try and reproduce I did the following:
I get the correct content type header returns for each 406 response.
Comment #3
Wim LeersThat would be wonderful :)
This is the relevant test coverage from:
\Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testGet()
(This is also where the @todo is that points to this very issue.)
\Drupal\Tests\rest\Functional\CookieResourceTestTrait::initAuthentication()
\Drupal\Tests\rest\Functional\EntityResource\Comment\CommentResourceTestBase::testPostDxWithoutCriticalBaseFields()
We should be able to remove all 3 of those
Accept
headers and tests should still pass.Let's see!
Comment #5
Wim LeersThis does not explain the root cause.
The root cause is:
\Drupal\Core\EventSubscriber\DefaultExceptionSubscriber
), not to be confused with the default serialization exception subscriber (\Drupal\serialization\EventSubscriber\DefaultExceptionSubscriber
) nor the default HTML exception subscriber (\Drupal\Core\EventSubscriber\DefaultExceptionHtmlSubscriber
), then…\Drupal\Core\EventSubscriber\DefaultExceptionSubscriber::onException()
is what handles the exception and$format = $this->getFormat($event->getRequest());
to determine in which format to format the error response\Symfony\Component\HttpFoundation\Request::getAcceptableContentTypes()
, which is the code that inspects theAccept
subscriberComment #6
damiankloip CreditAttribution: damiankloip at Acquia commentedOh, so this is a problem when serialization module is not enabled?
Comment #7
Wim LeersNo: see the quoted code in
EntityREsourceTestBase
: this happens when you request a non-existing format. i.e.:Accept
Will return a
text/plain
error response.Accept
Will return an
application/json
error response.If we don't support the
Accept
header, we really should not support it.\Drupal\Core\EventSubscriber\DefaultExceptionSubscriber
should not readRequest::getAcceptableContentTypes()
.Comment #8
Wim LeersBTW, I noticed we can actually already remove the
Accept
header inCookieResourceTestTrait
. That's the only one that's not actually related to this issue. See #2849474: \Drupal\Tests\rest\Functional\CookieResourceTestTrait::initAuthentication() should use the current test format, and should not send an Accept header for the patch that removes it.Comment #9
Wim Leers#2849474: \Drupal\Tests\rest\Functional\CookieResourceTestTrait::initAuthentication() should use the current test format, and should not send an Accept header landed, yay!
That means one hunk from the patch can disappear. No interdiff, because this is a rebase.
Comment #10
Wim LeersSo, actually, the thing is that we want to prove is that sending an
Accept
header has no effect. Then #3 and #9 are not what we want, but this is what we want.i.e.:
and
should BOTH result in a
text/plain
response.But that's not the case today.
This new patch modifies the test coverage to change it to the expectation described above. No interdiff, because different patch.
Comment #11
Wim LeersThe solution is simple: modify
\Drupal\Core\EventSubscriber\DefaultExceptionSubscriber::getFormat()
to not inspect theAccept
request header.Comment #15
Wim LeersClearly, #11 fixed the fails in #10, but it caused different fails in
CommentResourceTestBase::testPostDxWithoutCriticalBaseFields()
. Working on that.Comment #16
Wim LeersWe simply also need to update
\Drupal\Tests\rest\Functional\EntityResource\Comment\CommentResourceTestBase::testPostDxWithoutCriticalBaseFields()
now.Comment #17
Wim LeersComment #19
Wim LeersStill 3 errors, but 3 other ones!
Comment #20
Wim LeersComment #22
Wim LeersSo now we have failures in Quick Edit tests. Because those are the only tests that are asserting 4xx AJAX responses (i.e.
?_wrapper_format=drupal_ajax
). With the changes in #20, all exceptions are treated as fatal errors: that's what the code that I removed in #20 did:… but many of those exceptions are not even remotely related to fatal errors! In fact, the recently committed #2808233: REST 403 responses don't tell the user *why* access is not granted: requires deep Drupal understanding to figure out proved that: it added meaningful information about why access is denied (for 403 responses). There's nothing fatal about that. Yet because it's this exception subscriber that was generating the response, that's how it looked.
So the solution is fairly simple: make
ExceptionJsonSubscriber
not only handlejson
but also the 3 wrapper formats:Now we just need to adjust the expected error messages of the two tests failing in #20 to not have that
A fatal error occurred:
prefix, and BAM, tests are passing again!Comment #23
dawehnerI really like those changes.
+1. Those formats effectively return json as well.
Nice!
It is always good to replace a negative with a non negative assertion
Comment #24
Wim Leers#22 made me realize that we're actually running into a closely related bug: the fact that
\Drupal\serialization\EventSubscriber\DefaultExceptionSubscriber
only handles 4XX error responses, and not 500 error responses.For 500 error responses, we only have
\Drupal\Core\EventSubscriber\DefaultExceptionSubscriber
, which only ever handles 500 error responses forjson
, not for anything else. Not forhal_json
, not forxml
, not for any other registered serialization format.So, actually, we should revert the changes made in #20, because they might cause certain error responses to start working differently.
However, we need to do more than just add a
on500()
method. Because ThoseonSOMETHING
methods are only called by\Drupal\Core\EventSubscriber\HttpExceptionSubscriberBase::onException()
if the exception is aHttpExceptionInterface
exception, which excludes exceptions due to fatal errors.Then the end result is that we don't get
text/plain
error responses… but error responses serialized in the correct format! :)(This still needs test coverage in
\Drupal\Tests\serialization\Unit\EventSubscriber\DefaultExceptionSubscriberTest
to be expanded though.)Comment #25
Wim LeersHere's the expanded test coverage.
Comment #26
Wim LeersSimplification of the changes in
\Drupal\serialization\EventSubscriber\DefaultExceptionSubscriber
. Far fewer changes now!From
8 files changed, 152 insertions(+), 36 deletions(-)
to
8 files changed, 126 insertions(+), 31 deletions(-)
Most of this is expanded test coverage.
Comment #27
Wim LeersA round of self-review, that hopefully also explains all the parts of this patch. I think it explains it pretty clearly, because I have a clear explanation/justification for each change:
This is what the IS was originally about.
In working on this patch, I ran into fails for these tests, and this is the solution: not all exceptions are fatal errors. So the expected messages need to be updated to not have the
A fatal error occurred:
prefix.This is the original mess we needed to fix.
(And I see that that last comment is wrong, fixing that in my next reroll.)
This is the other problem that my initial attempt to fix the original problem uncovered: the exception subscriber for the
serialization
module is currently A) not yet supportingHttpException(500, …)
instances, B) not yet supporting fatal error exceptions.(And I see that the logic can be simplified slightly, doing that in my next reroll.)
This is the expanded test coverage for the fixes in the
serialization
module's exception subscriber.Comment #28
Wim LeersAddressing my own feedback from #27.
Comment #29
dawehnerI am not convinced that catching 500 is a good idea.
When you have a 500 you want to simply stop and not care about it. For a HTML request for example we changed the strategy in D8 to not try to render a HTML page, which helped us on a lot of problems. For the DX for example we get nice rendered errors, especially when you have xdebug enabled.
Comment #30
Wim LeersIf you want that, then we should also modify:
\Drupal\Core\EventSubscriber\DefaultExceptionSubscriber::onException()
, it currently does:Note how it's only checking
if ($exception instanceof HttpExceptionInterface) {
after it decided to already handle it!\Drupal\Core\EventSubscriber\DefaultExceptionSubscriber::onJson()
:All of that is only relevant for fatal exceptions!
I had started writing a comparison of the pros and cons of each. But you're right. There's no comparison: I like your proposal (and hence point 1), because I agree there is far less that can break. If fatal errors occur within the serializer, then the patch in #28 is going to cause a lot of pain. #22 would then be much better.
Reuploading #22, unchanged.
Comment #31
Wim LeersSelf-review:
All changing to
text/plain
, great!But then why does this one continue to use the MIME type of the format? This is a 500 response, it should also use
text/plain
!Investigating…
Nit:
Comment #32
Wim LeersThe code I cited in #30 is to blame:
In case of a fatal error for a non-HTML format, that… calls
onHtml()
anyway (intentionally so: to have that super reliable output in case of fatal errors, that don't rely on any other subsystems). That method then eventually sets a response like this:As you can see, that doesn't set a
Content-Type
header.So, when
Response::prepare()
is called to prepare it for sending, it does this:Tada! We're sending something that's somewhere in between plain text and HTML as if it actually contained content of the requested format.
The solution is simple: just always set the
'Content-Type' => 'text/plain'
header inonHtml()
.Comment #33
Wim LeersI think #32 is ready.
Note that by now,
\Drupal\Core\EventSubscriber\DefaultExceptionSubscriber
really always does the same thing: it always ends up callingonHtml()
for fatal errors. In other words: it's finally living up to its description:So I think we should rename the class (to
FatalErrorExceptionSubscriber
) and simplify the logic (get rid of the currentonException()
and make the currentonHtml()
the newonException()
, then get rid of everything else).But that… is for another issue.
If modules add additional formats
Comment #34
Wim Leers#32 caused a few more
text/html
responses to change totext/plain
. In updating those assertions, I also noticed a few incorrect comments directly impacted by this, so fixed those too.Comment #37
dawehnerI love this nice cleanup!
Comment #38
Wim Leers#37 :)
This caused two fails in
ExceptionHandlingTest
. Fixes are trivial. And prove that only fatal error responses are affected :)Comment #39
Wim LeersCreated follow-up per second half of #33: #2853300: Standardize fatal error/exception handling: backtrace for all formats, not just HTML.
Comment #40
Wim LeersComment #41
dawehnerGreat!
Comment #43
catchCommitted/pushed to 8.4.x, thanks!
Comment #44
Wim LeersYay, this unblocked #2853300: Standardize fatal error/exception handling: backtrace for all formats, not just HTML!