From f0a37de51f641dbd3905a646e5b56b6c65d1b547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 21 Sep 2026 10:51:34 +0200 Subject: [PATCH] Replace ClientException with three exceptions behind one ExceptionInterface 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. --- CLAUDE.md | 4 +- README.md | 43 ++++- UPGRADE-2.0.md | 49 ++++-- src/Assert.php | 24 +++ src/Client/Client.php | 35 +++- src/Client/ClientInterface.php | 6 +- src/Client/ErrorResponse.php | 65 +++---- src/Event/Parameters.php | 9 +- src/Exception/ClientException.php | 67 -------- src/Exception/ExceptionInterface.php | 12 ++ src/Exception/InvalidArgumentException.php | 13 ++ src/Exception/ResponseException.php | 60 +++++++ src/Exception/TransportException.php | 19 +++ src/Generator/FbqGenerator.php | 10 +- src/ValueObject/Fb.php | 5 +- src/ValueObject/Fbc.php | 4 +- src/ValueObject/Fbp.php | 4 +- tests/Client/ClientTest.php | 160 ++++++++++++++++-- tests/Client/ErrorResponseTest.php | 18 +- tests/Event/ContentTest.php | 3 +- tests/Event/CustomTest.php | 3 +- tests/Event/EventTest.php | 4 +- .../InvalidArgumentExceptionTest.php | 50 ++++++ ...tionTest.php => ResponseExceptionTest.php} | 86 ++++------ tests/Exception/TransportExceptionTest.php | 26 +++ tests/Generator/FbqGeneratorTest.php | 17 ++ tests/ValueObject/FbTest.php | 9 +- tests/ValueObject/FbcTest.php | 3 +- tests/ValueObject/FbpTest.php | 3 +- 29 files changed, 585 insertions(+), 226 deletions(-) create mode 100644 src/Assert.php delete mode 100644 src/Exception/ClientException.php create mode 100644 src/Exception/ExceptionInterface.php create mode 100644 src/Exception/InvalidArgumentException.php create mode 100644 src/Exception/ResponseException.php create mode 100644 src/Exception/TransportException.php create mode 100644 tests/Exception/InvalidArgumentExceptionTest.php rename tests/Exception/{ClientExceptionTest.php => ResponseExceptionTest.php} (50%) create mode 100644 tests/Exception/TransportExceptionTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 8a798e9..22795c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,9 @@ So to add a field: add the public property, map it in `getMapping()`, and regist **`Parameters` subclasses:** `Event` (the aggregate root — holds `User $userData`, `Custom $customData`, a list of `Pixel`, plus `metadata` for app-internal use that is never sent), `User` (customer matching data), `Custom` (event-specific data like value/currency/contents), `Content` (a single item in `Custom::$contents`). `Event` auto-generates `eventId` (random, for [deduplication](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event#event-id)) and `eventTime` in its constructor. `Event` is intentionally **not** `final` so consumers can subclass it into domain-specific events; the other data objects are `final`. -**`Client` (`src/Client/Client.php`)** — `sendEvent()` delegates to `sendPreparedEvent($event->prepare())`. `Event::prepare()` returns a `PreparedEvent` (`src/Event/PreparedEvent.php`): the `getPayload()` array plus the delivery information (event name and id, pixels, test event code), made of scalars/arrays/`Pixel` only so consumers can hash at capture time and queue it. The pixels are cloned so it is a snapshot, and `withoutAccessTokens()`/`withAccessTokens()` (immutable) keep the tokens out of the queue and restore them by pixel id before sending. `sendPreparedEvent()` first rejects pixels without an access token with a `ClientException`, before any request, so an event is never delivered to only some of its pixels (Meta's own error for a missing token does not mention the token). It then POSTs the payload (form-encoded) to `graph.facebook.com/v{ApiConfig::APIVersion}/{pixelId}/events` once per pixel (each pixel carries its own access token). Non-200 responses throw `ClientException` built from `ErrorResponse`. HTTP is fully PSR-based: PSR-18 client and PSR-17 factories are auto-discovered via `php-http/discovery` but can be injected with `setHttpClient()` / `setRequestFactory()` / etc. The client is `LoggerAware` and defaults to `NullLogger`. +**`Client` (`src/Client/Client.php`)** — `sendEvent()` delegates to `sendPreparedEvent($event->prepare())`. `Event::prepare()` returns a `PreparedEvent` (`src/Event/PreparedEvent.php`): the `getPayload()` array plus the delivery information (event name and id, pixels, test event code), made of scalars/arrays/`Pixel` only so consumers can hash at capture time and queue it. The pixels are cloned so it is a snapshot, and `withoutAccessTokens()`/`withAccessTokens()` (immutable) keep the tokens out of the queue and restore them by pixel id before sending. `sendPreparedEvent()` first rejects pixels without an access token with an `InvalidArgumentException`, before any request, so an event is never delivered to only some of its pixels (Meta's own error for a missing token does not mention the token). It then POSTs the payload (form-encoded) to `graph.facebook.com/v{ApiConfig::APIVersion}/{pixelId}/events` once per pixel (each pixel carries its own access token). A failure of the HTTP client is wrapped in a `TransportException`, and a non-200 response throws a `ResponseException` carrying the status code, the raw body and, when the body is in Meta's error format, the parsed `ErrorResponse`. HTTP is fully PSR-based: PSR-18 client and PSR-17 factories are auto-discovered via `php-http/discovery` but can be injected with `setHttpClient()` / `setRequestFactory()` / etc. The client is `LoggerAware` and defaults to `NullLogger`. + +**Exceptions (`src/Exception/`)** — everything the SDK throws implements `ExceptionInterface`. There are three concrete classes, one per thing a caller can do: `InvalidArgumentException` (extends SPL's; the caller's fault, never retry: bad cookie values, invalid event data, pixels without an access token, an unencodable payload), `TransportException` (no response; retry) and `ResponseException` (non-200; decide from `statusCode`/`errorResponse`). To keep that promise, never throw SPL exceptions or use `Webmozart\Assert\Assert` directly in `src/`: use `Setono\MetaConversionsApi\Assert`, an internal subclass whose failures throw the SDK's `InvalidArgumentException`, and wrap third-party exceptions (the Facebook `Normalizer`, PSR-18, `\JsonException`). `FbqGenerator` is the one place that does not throw: its output goes straight into a page, so it logs and returns an empty string when the data cannot be encoded. **`FbqGenerator` (`src/Generator/FbqGenerator.php`)** — the client-side counterpart. Generates the `fbq('init', ...)` / `fbq('track', ...)` JavaScript snippets, using the browser-context payload and reusing the same `eventId` so server and browser events deduplicate. `Event::isCustom()` decides between `track` and `trackCustom`. diff --git a/README.md b/README.md index 9f5e9c7..4decaed 100644 --- a/README.md +++ b/README.md @@ -129,20 +129,42 @@ $event->testEventCode = 'TEST12345'; ### Error handling -`sendEvent()` and `sendPreparedEvent()` throw a `ClientException` if Meta returns a non-2xx response. The message -contains Meta's error message, code, trace id and the raw response (including the user-facing explanation when Meta -provides one). They also throw it, without making any request, if one of the pixels has no access token: +Everything the SDK throws implements `Setono\MetaConversionsApi\Exception\ExceptionInterface`, so you can catch it all in +one place. There are three concrete exceptions, one for each thing you can do about a failure: + +| Exception | Thrown when | What to do | +|---|---|---| +| `InvalidArgumentException` | The SDK is given something it cannot work with: a pixel without an access token, event data Meta does not accept, a payload that cannot be encoded, a cookie value in the wrong format. Always thrown before any request is made | Fix the input. Retrying will not help | +| `TransportException` | The request never got a response, e.g. a network error or a timeout. The exception from your HTTP client is the previous exception | Retry | +| `ResponseException` | Meta, or a proxy in between, answered with anything but a 200 | Decide from `$e->statusCode` and `$e->errorResponse` | + +`ResponseException::$errorResponse` is the error Meta reported, with its `message`, `code`, `subcode`, `type`, `traceId`, +the `transient` flag and the user-facing texts. It is null when the body is not in Meta's error format, which typically +means the response came from a proxy. The raw body is always available as `$e->body`. ```php -use Setono\MetaConversionsApi\Exception\ClientException; +use Setono\MetaConversionsApi\Exception\ExceptionInterface; +use Setono\MetaConversionsApi\Exception\ResponseException; +use Setono\MetaConversionsApi\Exception\TransportException; try { $client->sendEvent($event); -} catch (ClientException $e) { - $logger->error('Could not send event to Meta', ['exception' => $e]); +} catch (TransportException $e) { + // no response at all: try again later +} catch (ResponseException $e) { + if ($e->statusCode >= 500 || true === $e->errorResponse?->transient) { + // try again later + } + + $logger->error('Meta rejected the event', ['exception' => $e, 'trace_id' => $e->errorResponse?->traceId]); +} catch (ExceptionInterface $e) { + $logger->error('Could not send the event to Meta', ['exception' => $e]); } ``` +`InvalidArgumentException` extends PHP's own `\InvalidArgumentException`, so a plain `catch (\InvalidArgumentException $e)` +works too. + ## Sending events later, e.g. through a queue `User` holds the raw email addresses, phone numbers and names until the payload is built, so an `Event` should not be @@ -165,8 +187,8 @@ $client->sendPreparedEvent($preparedEvent->withAccessTokens([ `withAccessTokens()` takes the tokens indexed by pixel id and leaves pixels that are not in the list as they are. Both return a new instance. If your queue is trusted with the access tokens, you can skip both calls. -If a pixel still has no access token when you send, the client throws a `ClientException` that names the pixel, before -any request is made. The event is therefore never delivered to only some of its pixels. +If a pixel still has no access token when you send, the client throws an `InvalidArgumentException` that names the +pixel, before any request is made. The event is therefore never delivered to only some of its pixels. ## Browser-side tracking with deduplication @@ -191,7 +213,8 @@ echo $generator->generateTrack($event); ``` Both methods wrap the output in a `