Adapter-agnostic error classification: telling "not found" apart from a transient or authorization failure
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:
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:
AwsS3V3—AwsException::getAwsErrorCode()(NoSuchKey,AccessDenied,SlowDown) plus the HTTP status.AzureBlobStorage/ theazure-ossadapter — Azure's error code is already a typed enum there (BlobErrorCode::BlobNotFound,AuthorizationPermissionMismatch,ServerBusy), so the mapping is direct.Local—is_file()/is_readable()on the failing path, or theerrnobehinderror_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
FileNotFoundexception 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
- Is this direction something you'd consider for 3.x (additive, default
Unknown), or is it strictly a 4.x concern? - If the scope feels too broad, would a narrower
NotFoundsignal — 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.
Source: thephpleague/flysystem