Skip to content

Report the Azure error code as the Flysystem failure reason - #71

Merged
brecht-vermeersch merged 2 commits into
php-oss-for-azure:mainfrom
cancan101:flysystem-populate-failure-reason
Aug 20, 2026
Merged

Report the Azure error code as the Flysystem failure reason#71
brecht-vermeersch merged 2 commits into
php-oss-for-azure:mainfrom
cancan101:flysystem-populate-failure-reason

Conversation

@cancan101

Copy link
Copy Markdown
Contributor

Problem

AzureBlobStorageAdapter converts SDK failures into Flysystem exceptions using only the named previous: argument:

// src/Storage/BlobFlysystem/AzureBlobStorageAdapter.php
throw UnableToReadFile::fromLocation($path, previous: $e);

UnableToReadFile::fromLocation() takes (string $location, string $reason = '', ?Throwable $previous = null), so reason() is left empty and the exception message is just:

Unable to read file from location: some/path.

That is unfortunate here specifically, because the SDK already has the information callers need. BlobStorageException carries errorCode, errorCodeValue, statusCode and requestId, so the difference between BlobNotFound, AuthorizationPermissionMismatch and ServerBusy is known at the throw site and then discarded from everything except getPrevious().

The practical consequence is that a caller wanting to distinguish "blob is missing" (fall back / regenerate) from "auth or throttling failure" (surface, retry, alert) has to unwrap getPrevious() and type-check against the SDK — which defeats the point of coding against the Flysystem interface. It also matters inside Flysystem itself: MountManager re-throws with $exception->reason(), so an empty reason propagates as an empty reason.

checksum() in this same file already passes $e->getMessage(), so the adapter was internally inconsistent too.

Change

Route the throw sites through a small private helper that prefixes the Azure error code to the service message when the cause is a BlobStorageException, and falls back to getMessage() otherwise:

private static function exceptionReason(\Throwable $e): string
{
    if ($e instanceof BlobStorageException && $e->errorCodeValue !== null) {
        return $e->errorCodeValue.': '.$e->getMessage();
    }

    return $e->getMessage();
}

Applied to write, read, readStream, delete, deleteDirectory, mimeType, lastModified, fileSize and checksum. $previous is still set everywhere, so nothing that inspects it today breaks. No signature or behavior changes beyond the reason string being populated.

This also aligns the adapter with the other Flysystem adapters — Local, GoogleCloudStorage and the built-in AzureBlobStorage all populate the reason. I've opened thephpleague/flysystem#1913 to fix the same gap in AwsS3V3Adapter.

Tests

  • Integration (tests/Storage/BlobFlysystem/Integration): it_reports_the_azure_error_code_as_the_failure_reason() reads a missing blob and asserts BlobNotFound appears in reason().
  • Unit (tests/Storage/BlobFlysystem/Unit): extended the existing invalid-transfer-size test to assert the reason is populated on the non-BlobStorageException path.

Verified locally against Azurite (mcr.microsoft.com/azure-storage/azurite) on PHP 8.3:

  • vendor/bin/phpunit tests/Storage/BlobFlysystem — 71 tests, 120 assertions, all passing.
  • The new integration test fails without the adapter change (Failed asserting that '' contains "BlobNotFound"), so it is not vacuous.
  • vendor/bin/pint --test — passed.
  • vendor/bin/phpstan --no-progress --memory-limit=2G — the 4 reported errors are pre-existing on a clean checkout (property.unused in tests/Storage/Blob/{Functional,Integration}/BlobClientTest.php); none are in the files touched here.

CHANGELOG.md for the package is updated under Unreleased. Nothing here is breaking, so UPGRADE.md is untouched.

Unrelated observation

While in the file: fileSize() throws UnableToRetrieveMetadata::lastModified($path, ...) rather than ::fileSize(...), so a failing fileSize() call reports itself as a last-modified failure. I left it alone to keep this PR to one concern — happy to send a separate PR if you'd like.

@cancan101

Copy link
Copy Markdown
Contributor Author

Note on the red PHPStan (8.5) check: it reports the same 4 pre-existing property.unused errors in tests/Storage/Blob/{Functional,Integration}/BlobClientTest.php, none of them in files this PR touches. The workflow has been failing on main since ffa2e91 / the 2026-06-30 runs, so it isn't a regression from this change — just flagging it so it isn't mistaken for one.

The adapter passed only `previous:` when converting SDK failures into
Flysystem exceptions, leaving reason() empty. BlobStorageException already
carries the Azure error code, so callers had to unwrap getPrevious() and
type-check against the SDK to tell a missing blob apart from an
authorization or throttling failure.

Route those throw sites through a helper that prefixes the error code to
the service message. checksum() already used getMessage(), so this also
makes the file internally consistent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@brecht-vermeersch

Copy link
Copy Markdown
Member

Hi Alex — thanks for the thoughtful PR and the detailed explanation. During review, I noticed that deleteDirectory() could receive a BlobStorageException wrapped inside UnableToListContents, which meant the Azure error code was still lost in that path.
I pushed a small follow-up commit that makes exceptionReason() traverse the previous-exception chain. I also added a unit test for multiple wrapper levels and an integration test covering deletion from a missing container.
The original behavior and approach remain unchanged; this just ensures nested failures receive the same treatment. Thanks again for the contribution!

@brecht-vermeersch
brecht-vermeersch merged commit d707c64 into php-oss-for-azure:main Aug 20, 2026
10 of 11 checks passed
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.

3 participants