Skip to content

Replace ClientException with three exceptions behind one ExceptionInterface - #18

Merged
loevgaard merged 1 commit into
2.xfrom
refactor-exceptions
Sep 21, 2026
Merged

loevgaard merged 1 commit into
2.xfrom
refactor-exceptions

Conversation

@loevgaard

Copy link
Copy Markdown
Member

Summary

The SDK had a single exception, ClientException, with three problems:

  • Meta's error was flattened into a string. ErrorResponse parsed the code, subcode, type, trace id and is_transient, and the exception then threw all of it away into the message. A queue handler deciding whether to retry had to parse text, and the HTTP status code was not available at all.
  • The interface's promise was false. @throws ClientException if the request failed in any way, yet the PSR-18 exception of the HTTP client (timeouts, DNS) and a \JsonException escaped unwrapped, and so did the SPL / webmozart exceptions thrown while building the payload in sendEvent().
  • One class for unrelated situations, and as a \RuntimeException base class it made a caller mistake such as a missing access token a runtime error. Its name also collides with PSR-18's ClientExceptionInterface.

Design

Everything the SDK throws implements ExceptionInterface, and there is one concrete class for each thing a caller can do about a failure:

Exception Thrown when What to do
InvalidArgumentException (extends SPL's) A cookie value in the wrong format, invalid event data, a pixel without an access token, a payload that cannot be encoded. Always before any request Fix the input, never retry
TransportException No response at all. Wraps the PSR-18 exception as previous Retry
ResponseException Any non-200. Carries statusCode, body and ?ErrorResponse Decide from the data
} catch (ResponseException $e) {
    if ($e->statusCode >= 500 || true === $e->errorResponse?->transient) {
        // retry
    }
}

How the "everything" promise is kept:

  • Setono\MetaConversionsApi\Assert (@internal) extends webmozart's Assert and overrides reportInvalidArgument(), so every failed assertion in src/ throws the SDK's InvalidArgumentException. PHPStan's type narrowing keeps working, since the assertion methods are still declared on the parent.
  • Fbc::fromString() / Fbp::fromString() throw it directly.
  • The \InvalidArgumentException of the Facebook Normalizer is wrapped, and the message now names the field: The value of the field "action_source" is invalid: ….
  • The client wraps the PSR-18 exception and the \JsonException from encoding the payload.
  • ErrorResponse is no longer @internal, since it is exposed through ResponseException::$errorResponse. Its properties are readonly and fromJson() throws InvalidArgumentException.
  • FbqGenerator::generateTrack() no longer lets a \JsonException escape. It logs and returns an empty string, which is what generateInit() already did; the output of both goes straight into a page.

Because the SDK's InvalidArgumentException extends PHP's, existing catch (\InvalidArgumentException $e) blocks, e.g. around Fbc::fromString(), keep working.

Deliberately not wrapped: Http\Discovery\Exception\NotFoundException when no PSR-18 client is installed. That is a setup problem with a good message of its own.

Backwards compatibility

Intentional breaks, all documented in UPGRADE-2.0.md with a 1.x to 2.0 mapping table: ClientException is removed, PSR-18 exceptions no longer escape from the client, ErrorResponse changed shape, and generateTrack() no longer throws. The Roave BC job is red by design.

Docs

README ("Error handling" rewritten around the three classes, plus notes in the queue, cookie and browser-side sections), CLAUDE.md (new Exceptions paragraph including the rule to use the internal Assert and wrap third-party exceptions), UPGRADE-2.0.md.

Test plan

  • Client: ResponseException with Meta's error (status code, body, parsed error), with a non-JSON body from a proxy (errorResponse null, previous set), stops at the first failing pixel, TransportException wrapping the HTTP client's exception, InvalidArgumentException for an unencodable payload and for missing access tokens. The exceptions are caught through ExceptionInterface and then asserted to be the concrete class
  • InvalidArgumentExceptionTest: catchable as the SDK interface, the SDK class and the SPL class; thrown by a failed assertion
  • ResponseExceptionTest (all message variants, null error response), TransportExceptionTest
  • Value objects, Event, Content, Custom and ErrorResponse tests now expect the SDK's exception; the invalid action_source test asserts the field name in the message
  • FbqGeneratorTest: generateTrack() logs and returns an empty string for unencodable custom data
  • 122 tests, 100 % line coverage; PHPStan (level max), ECS, dependency analyser green; Infection MSI 95 % / covered 95 %, no new escaped mutants

…erface

The single ClientException flattened Meta's structured error into a
message, so a caller could not tell a transient error from a permanent
one without parsing text. It also promised to cover "any" request
failure while the PSR-18 exception of the HTTP client and a JsonException
escaped unwrapped, and it made a caller mistake such as a missing access
token a RuntimeException.

Everything the SDK throws now implements ExceptionInterface, and there
is one concrete class for each thing a caller can do about a failure:

- InvalidArgumentException (extends SPL's): the caller has to fix the
  input, never retry. Thrown for a cookie value in the wrong format,
  invalid event data, a pixel without an access token and a payload
  that cannot be encoded.
- TransportException: no response at all, retry. Wraps the PSR-18
  exception.
- ResponseException: a non-200 response. Carries the status code, the
  raw body and, when the body is in Meta's error format, the
  ErrorResponse with code, subcode, type, trace id and the transient
  flag. ErrorResponse is no longer internal and is now readonly.

An internal Assert subclass makes every failed assertion in src/ throw
the SDK's InvalidArgumentException, and the exception of the Facebook
normalizer is wrapped with the name of the field added.

FbqGenerator::generateTrack() no longer lets a JsonException escape: it
logs and returns an empty string, like generateInit() already did, since
the output of both goes straight into a page.
@loevgaard
loevgaard merged commit 1fea047 into 2.x Sep 21, 2026
27 of 28 checks passed
@loevgaard
loevgaard deleted the refactor-exceptions branch September 21, 2026 08:56
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