#1914·flysystem

Adapter-agnostic error classification: telling "not found" apart from a transient or authorization failure

Author: cancan101Created Aug 8, 2026Updated Aug 8, 2026

Problem

There is no portable way to ask why a filesystem operation failed. UnableToReadFile (and its siblings) is thrown for a missing object, an authorization failure, a throttling response and a network timeout alike, so a caller that wants to react differently to each has to unwrap getPrevious() and type-check against the underlying SDK — which defeats the purpose of coding against FilesystemAdapter.

The concrete case that led me here: a thumbnail delivery path that streams a cached derivative from storage and regenerates it on a miss. readStream() throwing means "regenerate" — but it also means "S3 returned a 503", so a transient blip silently turns into full image reprocessing on every request. Getting that right requires distinguishing missing from failed, and today there is no adapter-agnostic way to do it.

Why reason() isn't sufficient

reason() is a free-text string, so at best callers end up substring-matching on adapter-specific wording. It's also populated inconsistently, which makes even that unreliable:

Adapter reason on read failure previous
Local error_get_last()['message'] none
GoogleCloudStorage $exception->getMessage() set
AzureBlobStorage $exception->getMessage() set
AwsS3V3 '' set

(#1913 fixes the AwsS3V3 row, and I've sent the equivalent fix to the community Azure SDK's adapter at php-oss-for-azure/azure-php#71. Those are worth doing on their own, but they only improve the human-readable message — they don't make the failure programmatically classifiable.)

Proposal

Add an opt-in classification to FilesystemOperationFailed, defaulting to "unknown" so existing adapters keep working unchanged:

php
enum FailureKind {
    case NotFound;
    case PermissionDenied;
    case Transient;      // throttling, 5xx, timeouts
    case Unknown;
}

interface FilesystemOperationFailed {
    // ...
    public function kind(): FailureKind;   // default: FailureKind::Unknown
}

Adapters then map what they already know, incrementally:

  • AwsS3V3AwsException::getAwsErrorCode() (NoSuchKey, AccessDenied, SlowDown) plus the HTTP status.
  • AzureBlobStorage / the azure-oss adapter — Azure's error code is already a typed enum there (BlobErrorCode::BlobNotFound, AuthorizationPermissionMismatch, ServerBusy), so the mapping is direct.
  • Localis_file() / is_readable() on the failing path, or the errno behind error_get_last().
  • Anything unmapped stays Unknown, which callers must handle anyway.

MountManager would forward kind() the way it already forwards reason().

Alternatives considered

  • A dedicated FileNotFound exception type. Cleaner to consume, but a breaking change to the exception hierarchy and an all-or-nothing migration across every adapter. The enum can be added without breaking anyone.
  • Leave it to callers. Workable for a single-adapter application, but it means every consumer re-implements the same per-adapter mapping, and libraries that support several storage backends can't do it at all.

Questions

  1. Is this direction something you'd consider for 3.x (additive, default Unknown), or is it strictly a 4.x concern?
  2. If the scope feels too broad, would a narrower NotFound signal — just enough to distinguish a missing object — be more palatable than the four-case enum?

Happy to implement whichever shape you prefer, including the per-adapter mappings.