Troubleshooting
Practical problem/solution guide for Pragmatic.Storage. Each section covers a common issue, the likely causes, and the fix.
File Saves But Returns Empty Content
Section titled “File Saves But Returns Empty Content”The SaveAsync call succeeds and returns a valid URI, but the stored file has zero bytes.
Checklist
Section titled “Checklist”-
Was the stream read before passing to SaveAsync? If you inspect or copy the stream before saving, the position is at the end. Reset with
ms.Position = 0(only works with seekable streams likeMemoryStream):ms.Position = 0; // Reset before savingvar uri = await _storage.SaveAsync(ms, fileName, container, ct); -
Is the IFormFile empty? Check
File.Length > 0before saving. A zero-length file produces a valid but empty storage entry. -
Was the stream disposed before SaveAsync completed? If the stream’s scope ends before the async save finishes, the content is lost. Use
awaiton the save call within the stream’susingscope.
404 When Accessing Stored Files (LocalDisk)
Section titled “404 When Accessing Stored Files (LocalDisk)”Files are saved to disk but HTTP requests to the URI return 404.
Checklist
Section titled “Checklist”-
Is
UseStaticFiles()in the middleware pipeline?app.UseStaticFiles(); // Must be called to serve files from wwwroot/ -
Does the base path match the static files root?
LocalDiskFileStoragesaves to{basePath}/files/. IfbasePathiswwwroot, static files serve fromwwwroot/. These must match. -
Is the request URL correct?
LocalDiskFileStoragereturns relative URIs like/files/photos/abc.jpg. Verify the client is requesting the exact URI (no extra path segments, correct casing on case-sensitive filesystems). -
Is the file actually on disk? Navigate to
{basePath}/files/{container}/and verify the file exists. If the file is missing, the save may have failed silently (check logs).
InvalidOperationException: Unable to Resolve IFileStorage
Section titled “InvalidOperationException: Unable to Resolve IFileStorage”At runtime, resolving IFileStorage throws because no implementation is registered.
Checklist
Section titled “Checklist”-
Did you call a registration method? One of these must be called before the host builds:
// Option 1: IPragmaticBuilderapp.UseStorage(sp => new LocalDiskFileStorage(basePath, sp.GetRequiredService<ILogger<LocalDiskFileStorage>>()));// Option 2: Direct DIbuilder.Services.AddLocalDiskStorage(basePath);// Option 3: Custom providerbuilder.Services.AddFileStorage<MyProvider>(); -
Is the registration conditional and the condition not met? If you use
if (env.IsDevelopment())to register local disk but the environment is not Development, no provider is registered. Ensure all code paths register a provider. -
Is the registration happening after
builder.Build()? Service registrations must happen beforeBuild().UseStorageonIPragmaticBuilderruns during the builder phase, so this typically works. ButAddLocalDiskStorageonIServiceCollectionmust be called beforeBuild().
InvalidOperationException: “Upload rejected: … exceeds the limit”
Section titled “InvalidOperationException: “Upload rejected: … exceeds the limit””SaveAsync throws InvalidOperationException with an “Upload rejected” message.
A size limit is configured and the upload exceeds it:
- LocalDisk: the
maxFileSizeBytesconstructor parameter (0= no limit, the default). - S3:
S3StorageOptions.MaxFileSizeBytes(0= no limit, the default).
Enforcement happens in two ways. Seekable streams are rejected up front by checking Length, before anything is written. Non-seekable streams (HTTP request bodies, chunked inputs) have no known length, so the limit is enforced mid-transfer: the copy aborts the moment the running byte total exceeds the limit. On LocalDisk the partial file is removed before the exception propagates.
-
Raise the limit (or set it to
0) if the file size is legitimate. -
Reject oversized uploads earlier at the endpoint with
[MaxFileSize(...)]so clients get a proper 4xx instead of a server-side exception. -
Note that
AddLocalDiskStorage(basePath)registers LocalDisk without a size limit. To set one, use factory registration:app.UseStorage(sp => new LocalDiskFileStorage(basePath,sp.GetRequiredService<ILogger<LocalDiskFileStorage>>(),maxFileSizeBytes: 10 * 1024 * 1024));
GetAsync Returns Null / ExistsAsync Returns False for a File That Exists (LocalDisk)
Section titled “GetAsync Returns Null / ExistsAsync Returns False for a File That Exists (LocalDisk)”GetAsync returns null (or ExistsAsync returns false, or DeleteAsync silently does nothing) even though the file is on disk.
LocalDiskFileStorage resolves every incoming URI safely against its basePath. URIs that are rooted (C:\..., UNC paths), contain traversal segments (../), or otherwise resolve outside the storage root are treated exactly like a missing file: null / false / no-op. A rejected path is deliberately indistinguishable from a not-found, so callers probing with crafted URIs learn nothing about the filesystem.
Checklist
Section titled “Checklist”- Pass back the URI returned by
SaveAsync, unmodified. Relative URIs look like/files/photos/abc.jpg. - Same
basePath? If the provider was constructed with a differentbasePaththan the one used when saving (e.g., changed between deployments, or different between two hosts), the resolved path points elsewhere. - No manual path building. Do not reconstruct URIs from filesystem paths – absolute paths are rejected by design.
ArgumentException When Reading or Deleting (Azure/S3)
Section titled “ArgumentException When Reading or Deleting (Azure/S3)”GetAsync, ExistsAsync, or DeleteAsync throws ArgumentException on the cloud providers.
Both cloud providers validate caller-supplied URIs so they cannot be used to address arbitrary storage:
- Azure – “The supplied URI host … does not match the configured storage account”: the URI’s host differs from the
BlobServiceClient’s account. Happens when URIs saved against one account (or against Azurite) are replayed against another. - S3 – “Cannot resolve … to an S3 key”: the URI is neither an
s3://URI nor prefixed with the configuredPublicBaseUrl. Happens whenPublicBaseUrlchanged after URIs were issued, or when the URI was hand-built.
Store and pass back the exact Uri returned by SaveAsync. If you change the storage account or PublicBaseUrl, previously issued URIs no longer resolve – migrate the stored references along with the configuration.
NotSupportedException When Generating a Signed URL
Section titled “NotSupportedException When Generating a Signed URL”ISignedUrlProvider.GetDownloadUrlAsync (or GetDownloadUrlAsResultAsync) fails because the provider cannot sign.
Signing needs a credential the client was not built with:
- Azure – a SAS URL requires the
BlobServiceClientto be created with a shared-key credential (a connection string or an account key). A client built from a managed identity or a token credential reportsCanGenerateSasUri == falseand the call throwsNotSupportedException. - Google Cloud – signing requires a service-account credential (a private key). The plain
StorageClientdoes not carry one; supply aUrlSignerinGoogleCloudStorageOptionsor setGOOGLE_APPLICATION_CREDENTIALS. - LocalDisk / InMemory / SFTP / FTP – these do not implement
ISignedUrlProviderat all; astorage is ISignedUrlProvidercheck returnsfalse. Serve their files another way (static files, or stream throughGetAsync).
Build the cloud client with a signing-capable credential, or fall back to streaming through GetAsync when storage is ISignedUrlProvider is false. The Result-based GetDownloadUrlAsResultAsync turns the NotSupportedException into a StorageWriteError whose Reason explains why signing was unavailable.
UriFormatException When Deleting Files
Section titled “UriFormatException When Deleting Files”Calling DeleteAsync with a URI reconstructed from a string throws UriFormatException.
LocalDiskFileStorage.SaveAsync returns a relative URI (new Uri("/files/photos/abc.jpg", UriKind.Relative)). If you store this as a string and reconstruct it with new Uri(string), the default constructor requires an absolute URI.
Store the Uri directly on your entity, not as a string:
// Entitypublic Uri FileUri { get; set; } = null!;
// Saveentity.FileUri = await _storage.SaveAsync(stream, fileName, container, ct);
// Delete -- same Uri, no conversionawait _storage.DeleteAsync(entity.FileUri, ct);If you must store as string (e.g., database column), reconstruct with UriKind:
var uri = new Uri(entity.FileUrl, UriKind.RelativeOrAbsolute);await _storage.DeleteAsync(uri, ct);Cloud Provider Throws at First Upload
Section titled “Cloud Provider Throws at First Upload”SaveAsync throws an exception from the cloud SDK (Azure, S3) on the first call.
Checklist
Section titled “Checklist”-
Are the provider’s constructor dependencies available?
UseStorage<T>()andAddFileStorage<T>()activate the type from the DI container, so the SDK client and the options must be registered too:// Azurebuilder.Services.AddSingleton(new BlobServiceClient(connectionString));builder.Services.AddSingleton(new AzureBlobStorageOptions { ContainerPrefix = "myapp-" });app.UseStorage<AzureBlobFileStorage>();// S3builder.Services.AddSingleton<IAmazonS3>(new AmazonS3Client());builder.Services.AddSingleton(new S3StorageOptions { BucketName = "my-app-uploads" });app.UseStorage<S3FileStorage>();Factory registration (
app.UseStorage(sp => new S3FileStorage(...))) sidesteps this entirely – everything is constructed inline. -
Are the credentials correct? Check the connection string, access key, or managed identity configuration.
-
Does the bucket exist? (S3)
S3FileStoragedoes not create the bucket – create it before the first upload. Azure is different:AzureBlobFileStoragecreates blob containers automatically on first save. -
Is the container name valid? (Azure) Azure blob container names must be lowercase letters, digits, and hyphens – no slashes, 3-63 characters.
ContainerPrefix + containermust satisfy these rules. Keep container names flat ("product-photos", never"products/photos"). -
Is the network accessible? Cloud storage requires network access. In restricted environments (corporate firewalls, air-gapped), HTTP calls to the storage service may be blocked.
File Deleted But Entity Still References It
Section titled “File Deleted But Entity Still References It”Users see broken images or download links that return 404.
The file was deleted from storage but the entity’s FileUri property was not cleared or the entity was not removed from the database. There is no transactional guarantee between IFileStorage and your persistence layer.
Always update the entity when deleting a file:
// Option 1: Delete the entity entirelyawait _storage.DeleteAsync(document.FileUri, ct);await _repository.RemoveAsync(document, ct);await _repository.SaveAsync(ct);
// Option 2: Clear the URIawait _storage.DeleteAsync(document.FileUri, ct);document.SetFileUri(null);await _repository.SaveAsync(ct);For maximum safety, delete the entity reference first (preventing further access), then delete the physical file.
Large File Uploads Fail with OutOfMemoryException
Section titled “Large File Uploads Fail with OutOfMemoryException”Uploading files larger than ~100 MB causes OutOfMemoryException.
If you read the entire file into a byte[] or MemoryStream before passing to SaveAsync, the full content is held in memory. For large files, this exhausts available memory.
Pass the IFormFile stream directly to SaveAsync without buffering:
await using var stream = File.OpenReadStream();var uri = await _storage.SaveAsync(stream, File.FileName, container, ct);If you need to buffer (e.g., for content inspection), consider streaming to a temporary file first:
var tempPath = Path.GetTempFileName();try{ await using (var tempFs = System.IO.File.Create(tempPath)) await File.CopyToAsync(tempFs, ct);
// Inspect the temp file // ...
await using var readStream = System.IO.File.OpenRead(tempPath); var uri = await _storage.SaveAsync(readStream, File.FileName, container, ct);}finally{ System.IO.File.Delete(tempPath);}Also check Kestrel’s request body size limit:
builder.WebHost.ConfigureKestrel(options =>{ options.Limits.MaxRequestBodySize = 200 * 1024 * 1024; // 200 MB});Can I use multiple storage providers simultaneously?
Section titled “Can I use multiple storage providers simultaneously?”Not with the default registration. IFileStorage is a single singleton. If you need different providers for different containers (e.g., local disk for temp files, S3 for permanent storage), implement a routing IFileStorage that delegates based on the container name.
Does IFileStorage support reading files?
Section titled “Does IFileStorage support reading files?”Yes. GetAsync(fileUri, ct) returns a Stream? – null if the file does not exist. The caller owns the stream; dispose it with await using (on the cloud providers, disposal also releases the underlying HTTP connection). Use it to stream private files to authorized users, post-process uploads, or read back payloads server-side – Pragmatic.Messaging’s claim-check pattern is built on it. Public files are still best served by the URI directly (static files, CDN, presigned URL).
Why is my file served as application/octet-stream?
Section titled “Why is my file served as application/octet-stream?”The cloud providers set the content type from the file’s extension via MimeTypes.GetMimeType. Extensions outside the known set (common image, document, data, and media formats) fall back to application/octet-stream. There is no content sniffing – the type derives entirely from the caller-supplied fileName, so treat it as untrusted metadata and validate uploads at the endpoint with [AllowedContentTypes].
Can I use LocalDiskFileStorage in Docker?
Section titled “Can I use LocalDiskFileStorage in Docker?”Yes, but mount a volume for persistence. Without a volume, files are stored in the container’s ephemeral filesystem and lost on restart:
volumes: - ./data/files:/app/wwwroot/filesHow do I test file upload endpoints?
Section titled “How do I test file upload endpoints?”Reference the Pragmatic.Storage.InMemory package and call services.AddInMemoryStorage() in your test setup. InMemoryFileStorage backs all four methods – SaveAsync, GetAsync, ExistsAsync, DeleteAsync – (and IFileInfoProvider) against a dictionary, without touching the filesystem or a cloud account, so an upload endpoint behaves as it would in production.
What happens if DeleteAsync is called with a non-existent URI?
Section titled “What happens if DeleteAsync is called with a non-existent URI?”Nothing – delete is idempotent on every provider. LocalDiskFileStorage checks File.Exists() and silently skips; AzureBlobFileStorage uses DeleteIfExistsAsync; S3FileStorage swallows 404/NoSuchKey responses. Custom providers should follow the same pattern.
Getting Help
Section titled “Getting Help”- GitHub Issues: github.com/pragmatic-design/Pragmatic.Design/issues
- Showcase Examples: See the
Showcaseproject for working file upload/download endpoints. - Providers: See custom-providers.md for the shipped Azure Blob and S3 providers and the contract for writing your own.
