Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Error handling with 3.2 is inconsistent & difficult #5963

Closed
j-schumann opened this issue Nov 10, 2023 · 33 comments
Closed

Error handling with 3.2 is inconsistent & difficult #5963

j-schumann opened this issue Nov 10, 2023 · 33 comments

Comments

@j-schumann
Copy link

j-schumann commented Nov 10, 2023

Description
With 3.2 there are two different ways in which error responses are created:

  1. When the problem occurs while routing or authentication, Symfony's own ProblemNormalizer is used.
  2. When the problem occurs within ApiPlatforms Processors/Providers an ApiResource\Error is created and serialized. (At least when using no deprecated features / the new problem responses)

This leads to multiple problems:

  • returned content + headers change for similar errors / same status codes: see Inconsistent content-type header & JSON structure for error/problem responses in 3.2.x #5961
  • We can no longer decorate 'api_platform.hydra.normalizer.error' to modify all returned error responses, e.g. to translate the returned error 'detail' / 'hydra:description'.
    • instead we have to create a new Processor to decorate 'api_platform.state_processor.serialize' to modify the ApiResource\Error error before it is serialized
    • and modify our existing serializer to instead decorate 'serializer.normalizer.problem' to do the same thing but on an array
  • ApiResource\Error is immutable, which requires us to create a new instance to modify a single value, e.g. the 'detail'
    • Why are there no setters, and if it is required to be immutable, can we please get ->with...() clone methods like most of the ApiPlatform\OpenApi\Model classes have (withParameters, withTrace, withDescription, ...)? Also the ORM|ODM|ElasticSearch\State\Options or the MetaData class(es) have them.

The feature request would be to simplify the modification of the error results, for all error sources together and to better document how this can be done. Currently the docs (https://api-platform.com/docs/core/errors/) neither shows how to use a Provider nor a Serializer to modify the response, and it does not mention the (current) differences in error handling & output.

Example
We want to translate the error messages generated by Symfony's routing / Lexik JWT / Validators / custom exceptions to be parseable by the API-Client.
Currently we have to use:

    $clone = new Error(
        $data->getTitle(),
        $this->translator->trans($data->getDetail(), [], 'validators'),
        $data->getStatus(),
        $data->originalTrace,
        $data->getInstance(),
        $data->getType(),
        $data->getHeaders(),
    );

and

        $result['detail'] = $this->translator
            ->trans($result['detail'], [], 'validators');

        // currently not available because of #5961 
        //$result['hydra:description'] = $this->translator
        //    ->trans($result['[detail](hydra:description)'], [], 'validators');

which also causes the Error constructor to iterate over the whole stacktrace again to remove the (already removed) args. (yes, we could first provide an empty array as trace and later simply set $data->originalTrace, see below)

Additional context

    defaults:
        extra_properties:
          rfc_7807_compliant_errors: true
          skip_deprecated_exception_normalizers: true
    event_listeners_backward_compatibility_layer: false
    keep_legacy_inflector: false

Additional questions / feedback

  • Why is (only) the originalTrace public in ApiResource\Error? What was the reason to not use a getter like with all other properties and an additional setter?
  • What is the reason behind making all properties private instead of protected? This prevents extending the Error with a custom subclass as it cannot access those props. It is neither marked final nor internal (which is a good thing imho).
    • This is a theme I see often in ApiPlatform (but also in Symfony in newer history): Classes are marked final or most/all properties and methods are private which prevents inheritance, which often causes repetition as we only want to change a small portion of the code but need to copy everything else as it cannot be re-used.
@nesl247
Copy link
Contributor

nesl247 commented Nov 10, 2023

#4997 is related. It got auto close unfortunately :(

@soyuka
Copy link
Member

soyuka commented Nov 11, 2023

I really need to document this better as you don't need so much complexity. For now you can disable the feature rfc_7807_compliant_errors: true right?

Why is (only) the originalTrace public in ApiResource\Error? What was the reason to not use a getter like with all other properties and an additional setter?

Because you can't create an exception from another exception, the trace will be replaced by PHP.

What is the reason behind making all properties private instead of protected? This prevents extending the Error with a custom subclass as it cannot access those props. It is neither marked final nor internal (which is a good thing imho).

IMHO it should be internal, I really got submerged with a bit too much work (especially working on #5882) and I should've spent more time into documenting this. My idea was that you should be able to own your exceptions and as these can be resources you can put everything you need without having to add normalizers (which are a pain).

Maybe that you can help me document this and see if something like this works (I don't have much time to try this out until next week).

  1. Declare your own error resource (just like ApiPlatform/ApiResource/Error)
  2. If you want to change how data looks create a Provider on this Error and use $context->attributes->get('exception') to get the actual exception.
  3. You may need to link the actual Exception class to your Error (see
    if ($this->resourceClassResolver?->isResourceClass($exception::class)) {
    ) and this is probably the "hard part" I see a few solutions though:
  • specify class inside the ErrorResource attribute
  • catch exceptions with a symfony listener and transform them to your own exception which is a resource
  • add a configuration to be able to use your own Error resource instead of ours ?

Let's help each others implementing this, I wish this was a little more mature and the whole work behind errors was meant to fix issues like #4997 not make things more complicated...

@j-schumann
Copy link
Author

j-schumann commented Nov 11, 2023

Thanks Antoine for your detailed answer!

I could help with documenting this, but first I need to fully understand this.

For now you can disable the feature rfc_7807_compliant_errors: true right?

This does not help, because even then Symfony's ProblemNormalizer is called:

ProblemNormalizer.php:63, Symfony\Component\Serializer\Normalizer\ProblemNormalizer->normalize()
ErrorNormalizer.php:31, App\Serializer\ErrorNormalizer->normalize()
Serializer.php:159, Symfony\Component\Serializer\Serializer->normalize()
Serializer.php:138, Symfony\Component\Serializer\Serializer->serialize()
SerializerErrorRenderer.php:60, Symfony\Component\ErrorHandler\ErrorRenderer\SerializerErrorRenderer->render()
ErrorController.php:41, Symfony\Component\HttpKernel\Controller\ErrorController->__invoke()
HttpKernel.php:181, Symfony\Component\HttpKernel\HttpKernel->handleRaw()
HttpKernel.php:76, Symfony\Component\HttpKernel\HttpKernel->handle()
ErrorListener.php:108, Symfony\Component\HttpKernel\EventListener\ErrorListener->onKernelException()
WrappedListener.php:116, Symfony\Component\EventDispatcher\Debug\WrappedListener->__invoke()
EventDispatcher.php:220, Symfony\Component\EventDispatcher\EventDispatcher->callListeners()
EventDispatcher.php:56, Symfony\Component\EventDispatcher\EventDispatcher->dispatch()
TraceableEventDispatcher.php:139, Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher->dispatch()
HttpKernel.php:239, Symfony\Component\HttpKernel\HttpKernel->handleThrowable()
HttpKernel.php:91, Symfony\Component\HttpKernel\HttpKernel->handle()
Kernel.php:197, Symfony\Component\HttpKernel\Kernel->handle()
[... phpunit]

I'm not sure if this is because of another configuration that should be changed. But actually I think it is a good thing to have this new standards compliant responses. But for all the existing clients to our API there should be no b/c break, because they all check for 'hydra:description', which currently is not present for routing errors (see #5961).

And this ProblemNormalizer creates the rfc_7807 JSON structure (but without the problem content-type header) that is later not decorated with the required Hydra props.

Because you can't create an exception from another exception, the trace will be replaced by PHP.

Yes, creating a new ApiResource\Error creates a new internal trace, but we are talking about the originalTrace which is already supplied to the constructor as array. So it could hold any arbitrary content already + the property is not overwritten from the base \Exception, what prevents making the property private and adding getters/setters?

My idea was that you should be able to own your exceptions and as these can be resources you can put everything you need without having to add normalizers (which are a pain).

It seems we misunderstand each other here: We do not want to modify the result just for some special endpoint/use case/exception. But (be able to) translate all error messages, regardless of source & type. Please see my example for reference. E.g. we translate symfony's "Invalid credentials." from BadCredentialsException to a parseable string as well as generic validation errors or custom exception messages.
Or did you mean we should create our own Error resource and register it so that it replaces ApiPlatform\ApiResource\Error completely? I would guess that is not fully possible, for example the ElasticSearch collection/item providers throw a new ApiResource\Error with no means to change it to another class.

Declare your own error resource (just like ApiPlatform/ApiResource/Error)

As above: I don't think that solves the problem. The main problem imho is, that currently not all errors that can occur are transformed to an ApiResource\Error which is then appropriatly handled / decorated with Hydra props / sent with the problem header by ApiPlatform. But that some (routing & auth) errors go through symfony's own error handling/serialization.

catch exceptions with a symfony listener and transform them to your own exception which is a resource

Probably this should be a new functionality in ApiPlatform: Intercept all errors thrown in Symfony (404, 405 from routing problems; 400, 401 from auth problems etc) and feed them into APIP's processor chain.

[Update:]
I looked at the code and saw that ApiPlatform\Symfony\EventListener\ExceptionListener is the reason that opens up two different ways of error handling, when it decides: "Normalize exceptions only for routes managed by API Platform"

Sure, APIP should not impose itself on other parts of the application that handle other routes. But that's difficult to decide: Currently, the ExceptionListener logic also decides that calling an APIP endpoint with the wrong HTTP method is not handled, which is (for me atleast) an unpreferable result. This also causes the problem detailed in #4997 as authentication routes - by lexikJwt or other auth systems - are exempt. But this also has two sides: LexikJwt answers in application/json for success responses and authentication errors per default. Simply disabling the check in the ExceptionListener only causes "error 400" responses (because of missing credentials) to be enhanced as Hydra responses, not all (success or error) responses.

I tried simply disabling the route check in the ExceptionListener, this already helped so far as 405 errors get the correct Hydra response. But they still don't get the problem content-header.
[end update]

Also, my problem with the ApiResource\Error itself would go away if there was a way to intercept APIP's serialization process of it. But currently the SerializeProcessor takes the Error object and spews out a JSON string. So to translate the 'detail' or 'hydra:description' we would have to go back into symfony's serializer chain - probably with another Normalizer (that you wanted to get rid of) - or decode the JSON, translate and encode again, which is not a good solution either.

@acirulis
Copy link
Contributor

acirulis commented Nov 13, 2023

We really need a way to preserve old behavior (before 3.2). API is used by frontend client, which relays on specific error format (hydra:description, etc)

Currently both config options:
skip_deprecated_exception_normalizers: false
rfc_7807_compliant_errors: false
does not help.

@soyuka
Copy link
Member

soyuka commented Nov 13, 2023

Hi everyone sorry really got caught up but we have lots of issues with errors right now so tomorrow I'll provide the 3.1 ErrorListener back with a flag to use the new system. This will give me a bit more time to work on this as there are too many things that are not working properly! Sorry about this, I really wanted to push forward this Error to Resource thing (which should be really helpful) but it looks like the implementation is too flaky right now.

In the meantime you can override our ErrorListener service with something like:

    protected function duplicateRequest(\Throwable $exception, Request $request): Request
    {
        $this->controller = 'error_controller';
        return parent::duplicateRequest($exception, $request);
     }

But I'd recommend to stay on 3.1 while I provide a proper fix. Thank you for your patience!

@soyuka
Copy link
Member

soyuka commented Nov 15, 2023

Please update to 3.2.5 and use rfc_7807_compliant_errors: false nothing should break as I fallback on the 3.1 code. I removed the deprecated normalizers for now and will introduce this back in 3.3 if needed.
Let me know if using false works.

I also found a way to make rfc_7807_compliant_errors: true actually usable but my priority is on fixing the API Platform website right now.

@mbrodala
Copy link
Contributor

mbrodala commented Nov 15, 2023

Not trying to complain too much for such an awesome platform given to us for free but really the latest error changes are a mess, especially because they are too complex to understand and not really well documented.

For example the latest update from 3.2.4 to 3.2.5 broke all of our tests because validation errors did not contain a violations key anymore. This could be fixed by this:

api_platform:
    defaults:
        extra_properties:
            rfc_7807_compliant_errors: true

So we need to enable the RFC7807 errors to restore the violations key and thus restore the structure from 3.1.x. You can imagine that I don't understand anything anymore. 😅

@soyuka
Copy link
Member

soyuka commented Nov 16, 2023

Indeed I'm reproducing that I tested manually I will add this to the test matrix!

@soyuka
Copy link
Member

soyuka commented Nov 17, 2023

Can someone try #5974 for the bc layer? I still need a bit more time to add this to our test suite but it looks promising with manual tests.

@j-schumann
Copy link
Author

Can someone try #5974 for the bc layer? I still need a bit more time to add this to our test suite but it looks promising with manual tests.

A very quick (!) test fixes the default behavior for me. Just tested a simple 404 to a non-existant route.

@soyuka
Copy link
Member

soyuka commented Nov 21, 2023

well it took quite some time but I'm close to something better which handles a proper bc layer and also makes that 3.2 system actually usable (it's not if you can't customize exceptions). It should be out soon I need to try a few more things manually. In my patch I reverted everything to the previous error handling when the flag is false and I added a way to customize exceptions using true!

Really sorry for the delay, I had to work (a lot) on the documentation as we had issues using Next.js and therefore we couldn't update the documentation pages. By the end of the week there will be a proper documentation page to help with the new Error stuff.

Stay tuned!

@acirulis
Copy link
Contributor

So, tried "update to 3.2.5 and use rfc_7807_compliant_errors: false":

BEFORE 3.2.x:

Lets take login 401 error (APP_DEBUG=false):

{
    "@context": "/api/contexts/Error",
    "@type": "hydra:Error",
    "hydra:title": "An error occurred",
    "hydra:description": "The presented password is invalid."
}

WITH 3.2.5 and rfc_7807_compliant_errors: false:

{
    "type": "https://tools.ietf.org/html/rfc2616#section-10",
    "title": "An error occurred",
    "status": 401,
    "detail": "Unauthorized"
}

So completely different result and even error response "The presented password is invalid" is removed.

WITH 3.2.5 and rfc_7807_complient_errors: true:

{
    "title": "An error occurred",
    "detail": "The presented password is invalid.",
    "status": 401,
    "type": "/errors/401"
}

better, but frontend client needs rewrite.

@soyuka
Copy link
Member

soyuka commented Nov 22, 2023

@acirulis please try with #5974 if you can (still fresh so CI should be green first)

"packages: {"api-platform/core": "dev-error-bc"},
"minimum-stability": "dev",
"repositories": [
  { "type": "vcs", "url": "https://github.com/soyuka/core" }
]

@acirulis
Copy link
Contributor

WITH "api-platform/core": "dev-error-bc as 3.2.5", and, DEBUG=false, and rfc_7807_compliant_errors: false:

{
    "type": "https://tools.ietf.org/html/rfc2616#section-10",
    "title": "An error occurred",
    "detail": "The presented password is invalid."
}

So detail message is back, but hydra attributes missing.

@soyuka
Copy link
Member

soyuka commented Nov 22, 2023

yes rfc_7807_compliant_errors: false gives you the bc layer.

What is the accept header of your request? I have tests covering your use case at https://github.com/api-platform/core/pull/5974/files#diff-3c956fa7911cf7b8d26c2541f0940b3943abcce72127ee14b2182ade1f089b7e and I just try this on a test project:

Having rfc_7807_compliant_errors: false in my defaults.

APP_ENV=dev:

20231122_14h55m19s_grim

APP_ENV=prod:

20231122_14h56m34s_grim

@acirulis
Copy link
Contributor

Ok, I see!
In Postman I was using Accept: */*. (However, it worked before 3.2.x without specific Accept Header).

Now I have two options:
Adding Accept: application/ld+json fixes it.

Other option is to add config:

    error_formats:
        jsonld: [ 'application/ld+json' ]      # Hydra error formats

so that other two choices (jsonproblem, jsonapi) are removed.

Thanks! Hope this fixes BC issues for me and others and you can move forward with new error handling & docs.

@soyuka
Copy link
Member

soyuka commented Nov 22, 2023

okay so I'll set error_formats with jsonld first it should do the trick, thanks! /edit I'm no sure that this is best though I think we should stick with problem+json when we're not sure of the content type required.

@acirulis
Copy link
Contributor

acirulis commented Nov 22, 2023

I mean, before 3.2.x jsonld was the default error format, it seems.
/edit or somehow correlated to

    formats:
        jsonld:
            mime_types: [ 'application/ld+json' ]

@soyuka
Copy link
Member

soyuka commented Nov 23, 2023

I managed to finish today please read #5974 and let me know if there's something not understandable. You can also try this patch although it'll be released tomorrow with its documentation.

@soyuka
Copy link
Member

soyuka commented Nov 24, 2023

I've released my patch, please let me know if things are not working with rfc_7807_compliant_errors: false.

rfc_7807_compliant_errors: true will break your existing errors as some things change in the response but it should work as well.

@j-schumann
Copy link
Author

Sorry, was caught up in other projects, came back to testing now.

rfc_7807_compliant_errors: true will break your existing errors as some things change in the response but it should work as well.

Yes it does break, it does not work as I expected:

3.2.4 + 3.2.5 worked so far as Symfony-triggered errors (No route found...) returned a Problem response (with detail, title, type) as well as the necessary Hydra properties.
3.2.6 + 3.2.7 return Hydra properties but are now missing the Problem properties (detail, title, type)

Same config for all tests:

          rfc_7807_compliant_errors: true
          skip_deprecated_exception_normalizers: true

@soyuka
Copy link
Member

soyuka commented Dec 18, 2023

@j-schumann
Copy link
Author

The Test you linked tests only an exception that is triggered by ApiPlatform, not an exception from the Symfony routing.

I would expect all those tests I added to pass: main...j-schumann:api-core:main

For the JSON-API tests: I'm not familiar with it, but imho either the description or the expectation does not match: For a response to be a rfc 7807 response, the type/title/detail props must be in the root of the response JSON, not encapsulated with a errors node. So for me "Get an rfc 7807 error" is inaccurate when neither the response body nor the content-type header match the definition in the RFC.

@soyuka
Copy link
Member

soyuka commented Dec 18, 2023

nice thanks! I'll check the spec on jsonapi it should indeed be at the root of the JSON (https://jsonapi.org/format/#error-objects)

@j-schumann
Copy link
Author

j-schumann commented Dec 18, 2023

nice thanks! I'll check the spec on jsonapi it should indeed be at the root of the JSON (https://jsonapi.org/format/#error-objects)

I'm not 100% on this one. While RFC 7807 Appendix B and RFC 9457 Appendix C state, that problem details can be embeded in other formats, RFC 9457 Section 3 (which superceedes 7807) clarifies how multiple errors should be returned:

{
 "type": "https://example.net/validation-error",
 "title": "Your request is not valid.",
 "errors": [
     {
       "detail": "must be a positive integer",
       "pointer": "#/age"
     },
     {
       "detail": "must be 'green', 'red' or 'blue'",
       "pointer": "#/profile/color"
     }
  ]
}

@soyuka
Copy link
Member

soyuka commented Dec 19, 2023

Actually reading the spec again none of the fields are mandatory, and having the errors inside the errors node is valid (https://datatracker.ietf.org/doc/html/rfc9457#name-the-problem-details-json-ob)

I also have a problem with

  Scenario: Get an rfc 7807 not found error
    When I add "Content-Type" header equal to "application/ld+json"
    And I send a "POST" request to "/does_not_exist" with body:
    """
    {}
    """
    Then the response status code should be 404
    And the response should be in JSON
    And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8"
    And the header "Link" should contain '<http://www.w3.org/ns/hydra/error>; rel="http://www.w3.org/ns/json-ld#error"'
    And the JSON node "@context" should not exist
    And the JSON node "type" should exist
    And the JSON node "title" should be equal to "An error occurred"
    And the JSON node "hydra:title" should be equal to "An error occurred"
    And the JSON node "detail" should exist
    And the JSON node "hydra:description" should exist

as we don't handle symfony errors.

I've attached a feature that would solve your issues, as this was not supported in 2.7 nor 3.x we need to introduce this as a new feature.

@j-schumann
Copy link
Author

Actually reading the spec again none of the fields are mandatory, and having the errors inside the errors node is valid (https://datatracker.ietf.org/doc/html/rfc9457#name-the-problem-details-json-ob)

OK, that settles the JSON API responses.

I also have a problem with

  Scenario: Get an rfc 7807 not found error
    When I add "Content-Type" header equal to "application/ld+json"
    And I send a "POST" request to "/does_not_exist" with body:
    """
    {}
    """
    Then the response status code should be 404
    And the response should be in JSON
    And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8"
    And the header "Link" should contain '<http://www.w3.org/ns/hydra/error>; rel="http://www.w3.org/ns/json-ld#error"'
    And the JSON node "@context" should not exist
    And the JSON node "type" should exist
    And the JSON node "title" should be equal to "An error occurred"
    And the JSON node "hydra:title" should be equal to "An error occurred"
    And the JSON node "detail" should exist
    And the JSON node "hydra:description" should exist

as we don't handle symfony errors.

I've attached a feature that would solve your issues, as this was not supported in 2.7 nor 3.x we need to introduce this as a new feature.

But API Platform handled those errors in 2.x and that wasn't deprecated/documented as "breaking change" in 3.0. Also, as I wrote in #5961, AP 3.1.x handled Symfony errors.
And also atleast 3.2.4 + 3.2.5 handled those errors. So to skip this now in >3.2.6 is a backwards incompatible change which would have to wait for 4.0.
Just enabling a new error format (with rfc_7807_compliant_errors, which adds some fields to the existing Hydra responses) should not mean that some errors are not handled anymore.

@soyuka
Copy link
Member

soyuka commented Dec 19, 2023

No the test you send me on the 404 (And I send a "POST" request to "/does_not_exist" with body:) doesn't work on the 3.1 branch. I'll settle to merge my fix but you'll need to bypass the symfony errors with the configuration flag. The one on PATCH should be fixed though.

@j-schumann
Copy link
Author

No the test you send me on the 404 (And I send a "POST" request to "/does_not_exist" with body:) doesn't work on the 3.1 branch.

I know, I wondered how that is. Because for all my AP projects this worked / still works with the correct versions. Maybe there are differences in how the project is configured for the tests, but I don't have any special code/config implemented to explicitly make this work.

There is a very simple test to verify:

 curl -X 'GET' 'https://demo.api-platform.com/bookz' -H 'accept: application/ld+json'

{"@context":"\/contexts\/Error","@type":"hydra:Error","hydra:title":"An error occurred","hydra:description":"No route found for \u0022GET https:\/\/demo.api-platform.com\/bookz\u0022"}

and:

curl -X 'PATCH' 'https://demo.api-platform.com/books' -H 'accept: application/ld+json'

{"@context":"\/contexts\/Error","@type":"hydra:Error","hydra:title":"An error occurred","hydra:description":"No route found for \u0022PATCH https:\/\/demo.api-platform.com\/books\u0022: Method Not Allowed (Allow: GET)"}j

For me that looks like API Platform handled those errors and produced a correct Hydra response.

@soyuka
Copy link
Member

soyuka commented Dec 19, 2023

Demo works on current 3.2, I think that the test issue is that it doesn't specify any accept header...

@j-schumann
Copy link
Author

Demo works on current 3.2

Exactly, and that shows two things:

  1. AP indeed handles symfony errors (and did so in 2.x + 3.1)
  2. 3.2.6 + 3.2.7 return Hydra properties but are now missing the Problem properties (detail, title, type) while rfc_7807_compliant_errors is enabled

@soyuka
Copy link
Member

soyuka commented Dec 19, 2023

My bad, demo works on 3.1 ^^. I'm trying another approach at #6056

@soyuka
Copy link
Member

soyuka commented Dec 20, 2023

v3.2.8 solves the latest reported issues.

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

No branches or pull requests

5 participants