{"record":{"id":"aecff67b38a90fe3","repo":"fullstackhero/dotnet-starter-kit","slug":"file-already-finalized","errorCode":null,"errorMessage":"file already finalized","messagePattern":"file already finalized","errorType":"exception","errorClass":"CustomException","httpStatus":409,"severity":"warning","filePath":"src/Modules/Files/Modules.Files/Features/v1/FinalizeUpload/FinalizeUploadCommandHandler.cs","lineNumber":47,"sourceCode":"{\n    public async ValueTask<FileAssetDto> Handle(FinalizeUploadCommand cmd, CancellationToken cancellationToken)\n    {\n        ArgumentNullException.ThrowIfNull(cmd);\n        var tenantId = currentUser.GetTenant() ?? throw new UnauthorizedException(\"invalid tenant\");\n        var userId = currentUser.GetUserId().ToString();\n\n        var asset = await db.FileAssets\n            .FirstOrDefaultAsync(f => f.Id == cmd.FileAssetId, cancellationToken)\n            .ConfigureAwait(false)\n            ?? throw new NotFoundException(\"file not found\");\n\n        if (!string.Equals(asset.CreatedByUserId, userId, StringComparison.Ordinal))\n        {\n            throw new ForbiddenException(\"not your pending file\");\n        }\n        if (asset.Status != FileAssetStatus.PendingUpload)\n        {\n            throw new CustomException(\"file already finalized\", (IEnumerable<string>?)null, HttpStatusCode.Conflict);\n        }\n\n        var head = await storage.HeadObjectAsync(asset.StorageKey, cancellationToken).ConfigureAwait(false)\n            ?? throw new CustomException(\"upload not received\", (IEnumerable<string>?)null, HttpStatusCode.Conflict);\n\n        // Allow declared+1% slack (S3 may differ slightly on multipart). Reject larger sizes.\n        var maxAllowed = asset.SizeBytes + Math.Max(1024L, asset.SizeBytes / 100);\n        if (head.SizeBytes > maxAllowed)\n        {\n            await storage.RemoveAsync(asset.StorageKey, cancellationToken).ConfigureAwait(false);\n            db.FileAssets.Remove(asset);\n            await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);\n            throw new CustomException(\n                $\"uploaded size ({head.SizeBytes}) exceeds declared ({asset.SizeBytes})\",\n                (IEnumerable<string>?)null,\n                HttpStatusCode.BadRequest);\n        }\n","sourceCodeStart":29,"sourceCodeEnd":65,"githubUrl":"https://github.com/fullstackhero/dotnet-starter-kit/blob/3f2959e683e9f83f13e55e1678c9119f63c7e8e5/src/Modules/Files/Modules.Files/Features/v1/FinalizeUpload/FinalizeUploadCommandHandler.cs#L29-L65","documentation":"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.","triggerScenarios":"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.","commonSituations":"Flaky networks plus non-idempotent client retry logic; load balancer replays; the client polls status and calls finalize twice concurrently; tests replaying captured requests.","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."],"exampleFix":"// before\nawait client.FinalizeUploadAsync(id);\nawait client.FinalizeUploadAsync(id); // 409 on retry\n// after\ntry { await client.FinalizeUploadAsync(id); }\ncatch (ApiException e) when (e.StatusCode == 409) { /* already finalized */ }\nvar asset = await client.GetFileAsync(id); // proceed with returned state","handlingStrategy":"try-catch","validationCode":"var asset = await client.GetFileAsync(assetId);\nif (asset.Status != \"PendingUpload\") return asset; // nothing to finalize","typeGuard":"bool IsPending(UploadStatus s) => s == UploadStatus.PendingUpload;","tryCatchPattern":"catch (ApiException e) when (e.StatusCode == HttpStatusCode.Conflict) {\n    return await client.GetFileAsync(assetId); // already finalized — treat as success\n}","preventionTips":["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."],"tags":["conflict","idempotency","upload","http-409"],"backgroundTag":"invalid-state-transition","analyzedSha":"3f2959e683e9f83f13e55e1678c9119f63c7e8e5","analyzedAt":"2026-09-15T22:20:53.684Z","contentChangedAt":"2026-09-15T22:20:53.684Z","schemaVersion":2},"datasetVersion":"2026-09-16T04:17:20.429Z"}