Error handling

The bundle draws a sharp line between failures a retry can fix and failures it never will. That line is what makes an unattended, large-scale import safe to run: transient problems heal themselves, and genuine problems surface instead of silently looping.

Two classes of failure

Recoverable

A recoverable failure is anything that might succeed if tried again later:

  • ESPN returns a 5xx server error.

  • A request times out or the network blips.

  • ESPN momentarily returns an empty or malformed body for a resource that does exist.

These surface as ordinary exceptions from the importer (often originating in the client). The handler lets them propagate, Messenger catches them, and your retry_strategy retries the message with backoff.

Unrecoverable

An unrecoverable failure is one where retrying the identical message can never help:

  • The reference URL cannot be resolved to the ids the importer needs (a malformed or unexpected $ref).

  • A required parent entity is not present in the database (e.g. importing an athlete whose season was never imported).

These are thrown as UnrecoverableImportException inside the importer. The handler catches it, logs it at critical, and rethrows it as Symfony Messenger’s UnrecoverableMessageHandlingException so the message bypasses the retry strategy and goes straight to the failure transport.

How handlers implement the split

Every handler follows the same two-arm catch structure:

try {
    $entity = $this->importer->buildEntityFromReference($message->reference);
    $this->entityManager->persist($entity);
    $this->entityManager->flush();

    // ... dispatch child messages ...
} catch (UnrecoverableImportException $e) {
    // permanent: log and do not retry
    $this->importLogger->critical('… error', [
        'message'   => $e->getMessage(),
        'reference' => $message->reference,
        'previous'  => $e->getPrevious()?->getMessage(),
    ]);
    throw new UnrecoverableMessageHandlingException($e->getMessage(), previous: $e);
} catch (\Throwable $e) {
    // transient: log and let Messenger retry
    $this->importLogger->warning('… error', [
        'message'   => $e->getMessage(),
        'reference' => $message->reference,
        'previous'  => $e->getPrevious()?->getMessage(),
    ]);
    throw $e;
}

The ordering matters: the specific UnrecoverableImportException arm comes first, the catch-all \Throwable arm second.

Where each exception is thrown

UnrecoverableImportException (in HansPeterOrding\EspnApiSymfonyBundle\Exception) is thrown by importers for:

  • URL-pattern resolution failures.

  • Parent-entity-not-found-in-database (season, team, competition, athlete, competitor, season type, …).

A plain ImportException (or any other \Throwable) is thrown for:

  • The primary resource not being returned by the ESPN API (which may be a transient ESPN hiccup and is therefore worth retrying).

Logging

The bundle logs through an injected importLogger (a PSR-3 logger). By convention:

  • Unrecoverable failures are logged at critical — they need a human to look.

  • Transient failures are logged at warning — they are expected noise that usually resolves on retry.

Wire importLogger to a dedicated Monolog channel if you want to isolate import logs from the rest of your application.

Inspecting failures

Permanently-failed messages land on the failure transport, where you can inspect the exception, the offending reference, and replay them once the root cause is fixed:

$ php bin/console messenger:failed:show
$ php bin/console messenger:failed:show <id> -vv
$ php bin/console messenger:failed:retry <id>

A common cause of an unrecoverable failure is import order: dispatching a child before its parent exists. If you see “… not found” criticals, check that the parent tree was imported first, or dispatch the parent message and let the cascade reach the child naturally.

Idempotency and replays

Because every importer uses a findByDtoOrCreateEntity style look-up, replaying a message is safe: it updates the existing row rather than creating a duplicate. This means you can retry failed messages liberally once you have addressed the underlying cause, without worrying about polluting your data.