Skip to content

[Serializer] Document SerializerInterface exceptions #59306

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

Merged
merged 1 commit into from
Jan 6, 2025

Conversation

VincentLanglet
Copy link
Contributor

@VincentLanglet VincentLanglet commented Dec 27, 2024

Q A
Branch? 7.3
Bug fix? no
New feature? no
Deprecations? no
Issues -
License MIT

This

  • warns the developper that calls to SerializerInterface::serialize and deserialize might throws exception
  • avoid PHPStan to report useless try/catch when writing
try {
     $serializer->serialize(...);
} catch (NotEncodableValueException) {} // for instance

SerializerInterface::serialize can throws

  • NotEncodableValueException
  • all exceptions thrown by NormalizerInterface::normalize
  • all exceptions thrown by EncoderInterface::encode

Since it might be a lot of exception and there is no real need to list them all, I simplified with @throws ExceptionInterface.

@wouterj
Copy link
Member

wouterj commented Dec 27, 2024

Any method everywhere might throw some type of \Exception. I'm personally not convinced this phpdoc adds any value (and if it does, we should add it everywhere?).

@VincentLanglet VincentLanglet changed the title [Serializer] Document SerializerInterface exceptions @VincentLanglet [Serializer] Document SerializerInterface exceptions Dec 27, 2024
@VincentLanglet
Copy link
Contributor Author

VincentLanglet commented Dec 27, 2024

Any method everywhere might throw.

This is not true. Some methods (a lot) doesn't throws Exception at all, even in the Symfony codebase.

And with well-documented methods, PHPStan helps a lot catching unwanted exceptions
https://phpstan.org/blog/bring-your-exceptions-under-control

Currently, with code like

try {
     $serializer->serialize(...);
} catch (NotEncodableValueException) {}

I'm getting

Dead catch - NotEncodableValueException is never thrown in the try block.

because the PHPdoc is not describing the exceptions thrown.

some type of \Exception.

Notice I didn't added \Exception but used the scoped interface Symfony\Component\Serializer\Exception\ExceptionInterface which already provides some value to

I'm personally not convinced this phpdoc adds any value

It adds value for static analysis tools (like PHPStan) users.
It also provide such value for IDE (like PHPStorm) users.

I'm personally not convinced this phpdoc adds any value (and if it does, we should add it everywhere?).

@throws ExceptionInterface exists on the NormalizerInterface

* @throws ExceptionInterface Occurs for all the other cases of errors

And a @throws tag exists on the EncoderInterface

I don't see a reason to add it to SerializerInterface which is a similar interface.

For reference, I already added missing @throws tag in some other class/interfaces like https://github.com/symfony/symfony/pull/57519/files or https://github.com/symfony/symfony/pull/52915/files

@fabpot
Copy link
Member

fabpot commented Dec 29, 2024

I agree with @wouterj. If we think it's useful, we should do it consistently across the board, not just on one interface. Maybe it's mostly useful for interfaces? But then, do we want to reference the interface or the concrete exception implementations that can be thrown?

@VincentLanglet
Copy link
Contributor Author

VincentLanglet commented Dec 29, 2024

I agree with @wouterj. If we think it's useful, we should do it consistently across the board, not just on one interface.

There is already a looot of method/class/interface already with @throws annotation.
And every times I find one missing I open (and still will open) a PR to fix it.

Maybe it's mostly useful for interfaces?

Since people works mainly with interface, it is.

This also explains which exceptions we are supposed to throw when implementing the interface.
Here custom SerializerInterface::serialize implementation should only throw Symfony\Component\Serializer\Exception\ExceptionInterface.
And the Symfony Serializer implementation respects this contract.

It throws:

  • Symfony\Component\Serializer\Exception\UnsupportedFormatException
  • Symfony\Component\Serializer\Exception\RuntimeException
  • Symfony\Component\Serializer\Exception\LogicException
  • Symfony\Component\Serializer\Exception\NotNormalizableValueException
  • All the exceptions thrown by NormalizerInterface::normalize (which includes ExceptionInterface)
    * @throws InvalidArgumentException Occurs when the object given is not a supported type for the normalizer
    * @throws CircularReferenceException Occurs when the normalizer detects a circular reference when no circular
    * reference handler can fix it
    * @throws LogicException Occurs when the normalizer is not called in an expected context
    * @throws ExceptionInterface Occurs for all the other cases of errors

And SerializerInterface::deserialize throws

  • Symfony\Component\Serializer\Exception\UnsupportedFormatException
  • Symfony\Component\Serializer\Exception\RuntimeException
  • All the exceptions thrown by DenormalizerInterface::denormalize (which includes ExceptionInterface)
    * @throws BadMethodCallException Occurs when the normalizer is not called in an expected context
    * @throws InvalidArgumentException Occurs when the arguments are not coherent or not supported
    * @throws UnexpectedValueException Occurs when the item cannot be hydrated with the given data
    * @throws ExtraAttributesException Occurs when the item doesn't have attribute to receive given data
    * @throws LogicException Occurs when the normalizer is not supposed to denormalize
    * @throws RuntimeException Occurs if the class cannot be instantiated
    * @throws ExceptionInterface Occurs for all the other cases of errors

But then, do we want to reference the interface or the concrete exception implementations that can be thrown?

So far, I would say both of the strategy are used. For instance

* @throws BadMethodCallException Occurs when the normalizer is not called in an expected context
* @throws InvalidArgumentException Occurs when the arguments are not coherent or not supported
* @throws UnexpectedValueException Occurs when the item cannot be hydrated with the given data
* @throws ExtraAttributesException Occurs when the item doesn't have attribute to receive given data
* @throws LogicException Occurs when the normalizer is not supposed to denormalize
* @throws RuntimeException Occurs if the class cannot be instantiated
* @throws ExceptionInterface Occurs for all the other cases of errors

We can list all the exceptions thrown with the explanation of the reason but we still have to add a
@throws ExceptionInterface "fallback" (like

* @throws ExceptionInterface Occurs for all the other cases of errors
) since

  • The SerializerInterface use the Normalizer/DenormalizerInterface which can throw any of these exceptions
  • The @throws on interfaces should be considered as "contract" about which exceptions are allowed to be thrown by custom implementation. And this interface should IMHO allows every scoped ExceptionInterface.

I considered it was already a good step to add @throws ExceptionInterface but do you prefer to be more explicit @fabpot ?

@wouterj
Copy link
Member

wouterj commented Dec 29, 2024

Thinking about this more, I'm actually thinking the @throw annotations can be "harmful" (and so is the PHPstan rule imho, if not all vendors use it).

They are documenting a contract ("this method will only throw things that are instance of X") while there is no way to enforce that. Even more, retrospectively adding it to methods creates a new contract for any implementation that they were never aware of.
In this case, our Serializer::serialize() will call normalizers. Maybe Symfony doesn't include any normalizer that throws something other than ExceptionInterface, but there is no way in knowing if any third party library and application normalizer conforms to this as well.
The same also applies to other implementations of the SerializerInterface out there. We never told them to only throw ExceptionInterface exceptions, so we can't make this contract to our users. That will wrongly inform them that they can completely ignore serializer exceptions by catching ExceptionInterface, which will lead to broken applications when an unexpected exception occurs.

I would say this means that adding @throw on interfaces might be much more dangerous than adding them on actual implementations.

@wouterj
Copy link
Member

wouterj commented Dec 29, 2024

And this is not a theoretical thing. It took me only a quick look to find Symfony itself does not conform this new contract proposed in the PR:

use Symfony\Component\Serializer\Encoder\YamlEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;

$encoders = [new YamlEncoder()];
$normalizers = [new ObjectNormalizer()];
$serializer = new Serializer($normalizers, $encoders);

$serializer->deserialize('[1', 'stdClass', 'yaml'); // throws Symfony\Component\Yaml\Exception\ParseException

@VincentLanglet
Copy link
Contributor Author

Thinking about this more, I'm actually thinking the @throw annotations can be harmful (and so is the PHPstan rule imho, if not all vendors use it).

PHPStan has two mode about the exceptions (https://phpstan.org/blog/bring-your-exceptions-under-control#what-does-absent-%40throws-above-a-function-mean%3F):

By default PHPStan consider that no @throws means it can throws anything, but it provides a stricter option to say that no @throws means never throw. It has to consider that it throws anything because of the big number of poor documented method/interface, but when using well documented API, it's powerful to report dead-catch which are often code smell or possible bugs. I already solve multiple errors because of it (like the fact of not catching the right exception).

Even more, retrospectively adding it to methods creates a new contract for any implementation that they were never aware of. [...] We never told them to only throw ExceptionInterface exceptions, so we can't make this contract to our users.

Technically, there is already a contract which say "This method is not supposed to throw any exception". And any implementation is breaking this contract. I'm trying to fix/improve the contract.

Symfony already rely on the possible exception thrown since some are caught in the SerializerErrorEncoder, even if none is documented

A better documentation would lead to a better try/catch.

That will wrongly inform them that they can completely ignore serializer exceptions by catching ExceptionInterface, which will lead to broken applications when an unexpected exception occurs.

Currently the interface is wrongly informing that no exception are thrown.
Adding a @throws Throwable would be the minimum needed changes but so far

@VincentLanglet
Copy link
Contributor Author

And this is not a theoretical thing. It took me only a quick look to find Symfony itself does not conform this new contract proposed in the PR:

use Symfony\Component\Serializer\Encoder\YamlEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;

$encoders = [new YamlEncoder()];
$normalizers = [new ObjectNormalizer()];
$serializer = new Serializer($normalizers, $encoders);

$serializer->deserialize('[1', 'stdClass', 'yaml'); // throws Symfony\Component\Yaml\Exception\ParseException

The issue is not about the SerializerInterface contract but about the YamlEncoder which already does not respect the EncoderInterface contract.

* @throws UnexpectedValueException
*/
public function decode(string $data, string $format, array $context = []): mixed;

This YamlEncoder was introduced in Symfony 3 while the @throws annotation exists since Symfony 2.
When looking at other Encoder, a lot of them are throwing NotEncodableValueException extends UnexpectedValueException in such situation.

So I would think the YamlEncoder need to be fixed.
I opened the PR #59323, and will be happy to add more fix if needed.

@OskarStark OskarStark changed the title [Serializer] Document SerializerInterface exceptions [Serializer] Document SerializerInterface exceptions Dec 29, 2024
@wouterj wouterj modified the milestones: 6.4, 7.3 Dec 29, 2024
fabpot added a commit that referenced this pull request Jan 6, 2025
…tLanglet)

This PR was merged into the 6.4 branch.

Discussion
----------

[Serializer] Fix exception thrown by `YamlEncoder`

| Q             | A
| ------------- | ---
| Branch?       | 6.4
| Bug fix?      | yes
| New feature?  | no
| Deprecations? | no
| Issues        | Fix the example given #59306 (comment)
| License       | MIT

DecoderInterface::decode is supposed to throw scoped exception cf https://github.com/symfony/symfony/blob/4078cb1642332cf5bbd45f5118edf766580cfb1e/src/Symfony/Component/Serializer/Encoder/DecoderInterface.php#L33-L35

but the YamlEncoder::decode could throw ParseException.
Others similar Encoder, like XmlEncoder or JsonEncoder are throwing NotEncodableValue so I did the same cf example
https://github.com/symfony/symfony/blob/da155534524f0d9c501023d10bb423fce4cd4482/src/Symfony/Component/Serializer/Encoder/JsonEncode.php#L41-L45

Commits
-------

ab9de2d Fix exception thrown by YamlEncoder
@nicolas-grekas
Copy link
Member

They are documenting a contract ("this method will only throw things that are instance of X") while there is no way to enforce that

That is certainly not what this annotation is about. Exceptions are for cases where something unexpected happens. In some cases, they're for signaling rare situations that shouldn't happen but can happen (the unhappy path). @throws is for those cases: it's augmenting the API with erroneous situations which, from a domain pov, can be caught and deal-with. Any other kind of exceptions can be thrown, but should be unhandled, unless specific reasons. (It should also be fine to leave API-defined exceptions unhandled, unlike what some IDEs think).

So to me: @throws should be crystal clear about when catching can make sense, from a domain pov.
A generic annotation with the interface doesn't fit this requirement to me and feels useless.
We should do better.

@VincentLanglet
Copy link
Contributor Author

So to me: @throws should be crystal clear about when catching can make sense, from a domain pov. A generic annotation with the interface doesn't fit this requirement to me and feels useless. We should do better.

I tried doing better by adding

  • NotEncodableValueException
  • NotNormalizableValueException

Would it be ok like this or should I be more explicit @nicolas-grekas ?
Having 0 throws annotation is not ok to me, but I don't think it will be better to list dozen of throw annotation either so I'm trying to find the right number.

Copy link
Member

@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, here are some more comments and that would be good on my side.

@nicolas-grekas nicolas-grekas changed the base branch from 6.4 to 7.3 January 6, 2025 14:14
@nicolas-grekas
Copy link
Member

Thank you @VincentLanglet.

@nicolas-grekas nicolas-grekas merged commit cdc1101 into symfony:7.3 Jan 6, 2025
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants