Skip to content

PMK-2865: stop leaking Guzzle's exception hierarchy - #169

Open
ewood-ac wants to merge 1 commit into
pmk-2061-php-versions-and-pipelinefrom
pmk-2865-transport-exception
Open

PMK-2865: stop leaking Guzzle's exception hierarchy#169
ewood-ac wants to merge 1 commit into
pmk-2061-php-versions-and-pipelinefrom
pmk-2865-transport-exception

Conversation

@ewood-ac

@ewood-ac ewood-ac commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Jira: PMK-2865

Stacked on #168 (PMK-2061 / v8.0.0) — review that first. Ships in v8.0.0, not a later major.

Problem

processRestRequest() documented @throws \GuzzleHttp\Exception\GuzzleException and left the request call unguarded, making the HTTP client's exception hierarchy part of this SDK's public contract.

That coupling is total, not partial: because the SDK sets http_errors => false and maps responses to PostmarkException itself, the transport family is the only Guzzle family that can reach a caller. It's 100% of the leaked surface.

Guzzle 8 reclassified exactly that family, and #168 widens the constraint to allow it. Composer resolves highest, so upgrading moves callers onto Guzzle 8 whether they ask or not — and this silently stops matching:

try {
    $client->sendEmail(...);
} catch (\GuzzleHttp\Exception\ConnectException $e) {
    $retryQueue->push($message);   // no longer runs on a timeout
}

Change

Transport failures — DNS, connect, TLS, timeout, socket — become Postmark\Models\PostmarkTransportException, which extends PostmarkException, so existing catch (PostmarkException $e) blocks keep working unchanged. The original Guzzle exception is preserved on getPrevious().

catch (\Postmark\Models\PostmarkTransportException $e) {
    if ($e->isTimeout())           { /* at-least-once retry */ }
    if ($e->isConnectionFailure()) { /* safe to retry */ }
}

The two majors are less alike than they look

All version-specific knowledge lives in one private classifier. This was established by inspecting the installed packages, not by reading docs:

Guzzle 7.15.2 Guzzle 8.0.2
ConnectException parent TransferException NetworkException
getHandlerContext() (the cURL errno) present absent
Timeout representation ConnectException + errno 28 NetworkTimeoutException / ConnectTimeoutException / ResponseTimeoutException

An errno-based classifier would have silently degraded to "unknown" on Guzzle 8 — and that was caught because the tests run under both majors, not because it was predicted. The classifier now matches short class name first, falls back to errno, then to the message, and never references a class that exists in only one major.

Deliberately unchanged: http_errors stays false

Raised during review and rejected on two grounds. It would bypass the body parsing that produces Postmark's own ErrorCode/Message — every catch (PostmarkException) would get a generic Guzzle error with no API detail. And it would add Guzzle's response-exception family (ClientException/ServerException, also re-parented in 8) to the caller surface, which is the opposite of this change. http_errors => false is what has been insulating this SDK from Guzzle 8's loudest break.

Verification

Green under both majors — PHPStan [OK], 97 tests, 0 failures on 8.0.2 and 7.15.2. The Guzzle matrix added in #168 runs both.

New tests/TransportExceptionTest.php (credential-free) covers: wrapping, catchability as PostmarkException, classification without importing GuzzleHttp, the no-errno fallback, and that a 401 is still a plain PostmarkException — a response that arrives is not a transport failure.

Breaking change

catch (GuzzleException) around SDK calls stops matching. Documented in the v8 upgrade notes with the before/after. Anyone catching PostmarkException is unaffected.

processRestRequest() documented @throws GuzzleException and left the request
call unguarded, so the HTTP client's exception types were part of this SDK's
public contract. That coupling was total rather than partial: because the SDK
sets http_errors => false and maps responses to PostmarkException itself, the
transport family is the ONLY Guzzle family that can reach a caller.

Guzzle 8 reclassified exactly that family, and PMK-2061 widens the constraint
to allow it. Composer resolves highest, so upgrading moves callers onto Guzzle
8 whether or not they ask -- and catch (ConnectException) around a send stops
matching a timeout, silently, with no code change on their side.

Transport failures are now PostmarkTransportException extends PostmarkException,
so existing catch (PostmarkException) keeps working and the original is on
getPrevious(). isTimeout() and isConnectionFailure() let callers make retry
decisions without importing anything from GuzzleHttp.

All version-specific knowledge is confined to one private classifier, because
the two majors are less similar than they look -- established by inspecting the
installed packages rather than the docs:

  Guzzle 7  ConnectException covers connect AND timeout, and carries the cURL
            errno on getHandlerContext(), which is what separates them.
  Guzzle 8  ConnectException is re-parented under NetworkException, DROPS
            getHandlerContext() entirely, and adds ConnectTimeoutException,
            NetworkTimeoutException and ResponseTimeoutException instead.

So errno-based classification alone would have silently degraded to "unknown"
on Guzzle 8 -- caught because the tests run under both majors, not because it
was predicted. The classifier matches on short class name first, falls back to
errno, then to the message, and never references a class that exists in only
one major.

Deliberately unchanged: http_errors stays false. Flipping it would bypass the
body parsing that produces Postmark's own ErrorCode/Message, and would ADD
Guzzle's response-exception family to the caller surface -- the opposite of
this change.

Verified green under both 8.0.2 and 7.15.2: PHPStan clean, 97 tests, 0
failures. The PMK-2061 CI matrix runs both.
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