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

  1. Treat 409 'file already finalized' as success and re-fetch the asset via the get-file endpoint.
  2. Add client-side single-flight guards (disable button, dedupe in-flight requests by FileAssetId).
  3. Ensure retry policies do not retry on 409 Conflict responses.
  4. 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

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


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)