fullstackhero/dotnet-starter-kit · warning · CustomException
file already finalized
Error message
file already finalized
What it means
After ownership checks, the handler verifies asset.Status == FileAssetStatus.PendingUpload; any other status means the finalize step already ran (or the asset was invalidated), and the handler throws CustomException with HttpStatusCode.Conflict. Finalize is intentionally non-idempotent to guarantee quota is debited and the FileFinalizedIntegrationEvent is emitted exactly once.
Solutions
- Treat 409 'file already finalized' as success and re-fetch the asset via the get-file endpoint.
- Add client-side single-flight guards (disable button, dedupe in-flight requests by FileAssetId).
- Ensure retry policies do not retry on 409 Conflict responses.
- If the asset ended in a bad non-pending status unexpectedly, inspect audit/status history rather than re-finalizing; re-initiate the upload if needed.
Example fix
// before
await client.FinalizeUploadAsync(id);
await client.FinalizeUploadAsync(id); // 409 on retry
// after
try { await client.FinalizeUploadAsync(id); }
catch (ApiException e) when (e.StatusCode == 409) { /* already finalized */ }
var asset = await client.GetFileAsync(id); // proceed with returned state Defensive patterns
Strategy: try-catch
Validate before calling
var asset = await client.GetFileAsync(assetId); if (asset.Status != "PendingUpload") return asset; // nothing to finalize
Type guard
bool IsPending(UploadStatus s) => s == UploadStatus.PendingUpload;
Try / catch
catch (ApiException e) when (e.StatusCode == HttpStatusCode.Conflict) {
return await client.GetFileAsync(assetId); // already finalized — treat as success
} Prevention
- Never retry finalize on 409; configure retry policies to exclude Conflict.
- Single-flight finalize per FileAssetId (in-flight dedupe, disabled submit button).
- After finalize, fetch current asset state instead of assuming.
- Poll the get-file endpoint to confirm status transitions rather than re-submitting.
When it happens
Trigger: Duplicate submission of finalize for the same FileAssetId: double-click, HTTP retry after a timeout though the first request succeeded, message-queue redelivery, or replaying an old finalize request.
Common situations: Flaky networks plus non-idempotent client retry logic; load balancer replays; the client polls status and calls finalize twice concurrently; tests replaying captured requests.
Understand the failure class
Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.
Related errors
- A brand with name ' ' already exists.
- Another brand with name
- A category with name
- Cannot delete a category that has child categories. Move or…
- A product with SKU ' ' already exists.
AI-assisted analysis of fullstackhero/dotnet-starter-kit@3f2959e683 (2026-09-15).
Data as JSON: /api/errors/aecff67b38a90fe3.
Report an issue: GitHub.
Appendix: source
Thrown at src/Modules/Files/Modules.Files/Features/v1/FinalizeUpload/FinalizeUploadCommandHandler.cs:47
{
public async ValueTask<FileAssetDto> Handle(FinalizeUploadCommand cmd, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(cmd);
var tenantId = currentUser.GetTenant() ?? throw new UnauthorizedException("invalid tenant");
var userId = currentUser.GetUserId().ToString();
var asset = await db.FileAssets
.FirstOrDefaultAsync(f => f.Id == cmd.FileAssetId, cancellationToken)
.ConfigureAwait(false)
?? throw new NotFoundException("file not found");
if (!string.Equals(asset.CreatedByUserId, userId, StringComparison.Ordinal))
{
throw new ForbiddenException("not your pending file");
}
if (asset.Status != FileAssetStatus.PendingUpload)
{
throw new CustomException("file already finalized", (IEnumerable<string>?)null, HttpStatusCode.Conflict);
}
var head = await storage.HeadObjectAsync(asset.StorageKey, cancellationToken).ConfigureAwait(false)
?? throw new CustomException("upload not received", (IEnumerable<string>?)null, HttpStatusCode.Conflict);
// Allow declared+1% slack (S3 may differ slightly on multipart). Reject larger sizes.
var maxAllowed = asset.SizeBytes + Math.Max(1024L, asset.SizeBytes / 100);
if (head.SizeBytes > maxAllowed)
{
await storage.RemoveAsync(asset.StorageKey, cancellationToken).ConfigureAwait(false);
db.FileAssets.Remove(asset);
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
throw new CustomException(
$"uploaded size ({head.SizeBytes}) exceeds declared ({asset.SizeBytes})",
(IEnumerable<string>?)null,
HttpStatusCode.BadRequest);
}
View on GitHub (pinned to 3f2959e683)