levelmax World 6 Errors & Edges
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. B
L
Level 6.2 ~14 min Target: PHPStan max

Never swallow

The nightly renewal run has a catch (Throwable $e) around each customer, with a comment: one bad card mustn't stop everyone else renewing. That's a good reason, and it was fine for a year.

Then the payment gateway went down for an hour. Every charge timed out, every timeout was caught, and every customer was recorded as declined. In the morning, 2,000 people got an email saying their card had bounced. Not one card had been looked at.

A catch is a claim

Every catch says: I know what this failure means, and I know what to do about it. catch (CardDeclined $e) makes that claim about a declined card, and it's true: record it and move on. catch (Throwable $e) makes the same claim about everything that could ever go wrong, including a gateway outage, a TypeError, a typo in a method name and the database going away. It's never true.

A swallowed exception is worse than a crash. A crash is loud, has a stack trace, and stops before it does more damage. A swallow turns a bug into wrong data, quietly, and the first anyone hears of it is a customer.

Logging and carrying on is still swallowing. A log line nobody is paged for, next to 2,000 wrong records, is not handling.

Catch narrowly

So catch the specific failures you have a plan for, and give each one its own plan:

try {
    $chargeId = $this->payments->charge($subscription->customer, $subscription->pence);
} catch (CardDeclined $e) {
    $report->addDecline($subscription->customer, $e->reason);
    continue;
} catch (GatewayUnavailable $e) {
    throw RenewalRunAborted::after($report, $e);
}

$report->addRenewal($subscription->customer, $chargeId);

Notice how small the try is. Only charge() can decline a card, so only charge() goes inside. If addRenewal() were in there too, a mistake in it could still land in a catch that was never written for it.

Anything not caught here, from a TypeError to a full disk, goes up to code that knows more, or all the way to the framework's error handler, which logs it properly and shows an error page. That's the one place a catch-all belongs.

Wrap, and keep the cause

The outage can't be handled here, but the run knows something the gateway doesn't: who has already been charged. That's worth adding before passing it on. Throw your own exception, carrying that, with the original as its previous:

parent::__construct($message, previous: $previous);

The log shows both, "Renewals stopped after 412" caused by "Connection to payments.example.com timed out", and a retry can skip the 412.

Power-up

Before
<?php

foreach ($due as $subscription) {
    try {
        $chargeId = $this->payments->charge($subscription->customer, $subscription->pence);
        $report->addRenewal($subscription->customer, $chargeId);
    } catch (Throwable $e) {
        // An outage, a TypeError, a typo: all "declined"
        $report->addDecline($subscription->customer, $e->getMessage());
    }
}
After
<?php

foreach ($due as $subscription) {
    try {
        $chargeId = $this->payments->charge($subscription->customer, $subscription->pence);
    } catch (CardDeclined $e) {
        $report->addDecline($subscription->customer, $e->reason);
        continue;
    } catch (GatewayUnavailable $e) {
        throw RenewalRunAborted::after($report, $e); // previous: $e
    }

    $report->addRenewal($subscription->customer, $chargeId);
}

Quick check

1. A loop catches Throwable around each item, logs it, and carries on. What's wrong with that?
2. Why should addRenewal() go after the try, rather than inside it?
3. You catch GatewayUnavailable and throw your own RenewalRunAborted. What must you pass it?

CHALLENGE

Last night the payment gateway was down for an hour. The renewal run caught the timeout like any other failure, recorded all 2,000 customers as declined, and the morning job emailed every one of them to say their card had bounced.

Make RenewalRun catch only what it can handle. A CardDeclined is recorded with the bank's reason, and the run carries on. A GatewayUnavailable stops the run with a RenewalRunAborted, which you'll write: it carries the report so far, so the retry knows who's already been charged, and keeps the outage as its previous exception. Anything else is a bug, and goes straight through.

REFERENCE
parent::__construct($message, previous: $previous); // getPrevious() returns it

catch blocks are tried in order, and the first one whose type matches wins.

Running on PHP 8.3…

Run the tests, run PHPStan, or submit for both. Ctrl/⌘ + Enter submits from the editor.