fix(uploads): treat a missing storage object as absent metadata, not a failure - #6378
Conversation
…a failure A workspace file is rewritten under a new key on every content update and the superseded object is deleted, so any reader holding the previous key finds nothing. getFileMetadata's provider lookups let that not-found propagate, so authorization's catch-all logged it at ERROR and never reached the branch already written for it. Return the function's established empty value instead, and collapse the three divergent per-provider not-found predicates onto one.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryMedium Risk Overview Storage metadata: Shared predicate: New Serve route: Tests cover the predicate, provider HEAD behavior, Reviewed by Cursor Bugbot for commit fac2ed9. Configure here. |
Azure raises a RestError whose name carries the class and whose code carries the reason, so testing name first and falling back to code only when name was absent missed BlobNotFound outright — narrower than the per-provider check it replaced.
Greptile SummaryThe PR changes missing-object handling so S3 and Azure metadata lookups report absence without swallowing provider or configuration failures, while GCS continues propagating ambiguous 404 responses. It also records expected file-not-found responses at informational severity while preserving error logging for other failures.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains; the previously reported GCS bucket-failure issue is fixed because ambiguous GCS 404 responses now propagate from metadata lookup rather than being converted to absent metadata.
|
| Filename | Overview |
|---|---|
| apps/sim/lib/uploads/core/storage-client.ts | Delegates S3 and Azure metadata lookups to provider-owned HEAD helpers while preserving GCS failure propagation. |
| apps/sim/lib/uploads/core/errors.ts | Introduces shared object-not-found classification with explicit S3 bucket and Azure container exclusions. |
| apps/sim/lib/uploads/providers/s3/client.ts | Maps object-level HEAD 404 responses to null while propagating bucket, permission, and service failures. |
| apps/sim/lib/uploads/providers/blob/client.ts | Uses the shared classifier for Blob HEAD and multipart-cleanup absence handling. |
| apps/sim/lib/uploads/providers/gcs/client.ts | Reuses the shared predicate in the existing multipart completion lookup without changing metadata-dispatch behavior. |
| apps/sim/app/api/files/serve/[...path]/route.ts | Routes expected FileNotFoundError logging through an informational path while retaining error logging for other exceptions. |
Sequence Diagram
sequenceDiagram
participant Route as File Serve Route
participant Auth as File Authorization
participant Metadata as Metadata Dispatcher
participant Provider as S3/Azure Provider
Route->>Auth: verify file access
Auth->>Metadata: getFileMetadata(key)
Metadata->>Provider: HEAD object
alt Object exists
Provider-->>Metadata: metadata
Metadata-->>Auth: metadata
else Object is absent
Provider-->>Metadata: null
Metadata-->>Auth: "{}"
Auth-->>Route: deny as not found
Route-->>Route: log at info
else Provider/configuration failure
Provider--xMetadata: throw error
Metadata--xAuth: propagate
end
Reviews (7): Last reviewed commit: "test(uploads): cover the Blob not-found ..." | Re-trigger Greptile
|
@cursor review |
… path NoSuchBucket and ContainerNotFound also answer 404, so the status-only match read a total storage misconfiguration as an absent object — every file read would fail closed with nothing left to alert on.
|
@cursor review |
…as absent GCS answers a missing object and a missing bucket identically, so a bare 404 cannot be attributed to the object by a dispatcher that does not know what was requested. getFileMetadata now takes the labelled check and leaves an unlabelled 404 propagating as before; the provider clients keep the lenient form, which is what each already used.
|
@cursor review |
…helpers getFileMetadata re-implemented the S3 and Blob HEAD calls inline, so it had to inspect provider errors itself and needed a second, stricter predicate to do it safely. headS3Object and headBlobObject already perform exactly those calls and already report absence as null, so delegating removes the duplication, the error inspection, and the extra predicate at once. GCS keeps raising, as before. Covers the real provider path in the S3 client's own suite, where mocking the seam had been hiding whether the two layers agree.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit bcde675. Configure here.
Each serve handler rethrows into the outer one, so a superseded key produced two ERROR lines for what is an ordinary 404 — two thirds of this module's error volume. Route all five catch sites through one helper that reserves error for failures that are actually the server's fault, matching how DocCompileUserError is already handled a few lines above.
|
@cursor review |
…w governs S3 and GCS already asserted absence and non-404 rethrow; Blob asserted neither, so the container-level exclusion went unverified on the one provider whose error puts the reason in code rather than name.
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit fb85639. Configure here.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit fac2ed9. Configure here.
Summary
Root cause of the chronic
FilesServeAPI/FileAuthorizationERROR noise — 1,000–2,000 lines/day for at least 14 days, peaking at 2,105 on 07-29.A workspace file is rewritten under a new key on every content update, and the superseded object is deleted immediately (
cleanupWorkspaceStorageObject(oldKey, 'version replacement'), from #5991). Any reader still holding the previous key therefore finds nothing — which is an ordinary outcome, not a failure.getFileMetadatalet that not-found propagate out of all three provider branches.verifyWorkspaceFileAccesscaught it in its generic handler and loggedERROR, so the branch already written for exactly this case —logger.warn('Workspace file missing authorization metadata')atauthorization.ts:264— was unreachable.Changes
getFileMetadatareturns{}when the object is absent — the same value it already returns when no provider is configured, so no contract or caller changesERRORNotFound/NoSuchKey, AzureBlobNotFound, GCS numeric404) onto one sharedisObjectNotFoundError, rather than adding a fourth copyVerified against the AWS SDK v3 source:
HeadObjectCommandthrowsNotFoundandGetObjectCommandthrowsNoSuchKey, both__BaseExceptionwith$fault: 'client'and$metadata.httpStatusCode: 404— matching the production payload exactly.Not in scope
This corrects how absence is modelled. The underlying design issue — URLs addressing a mutable storage key rather than the stable file id — is a separate change; a stale key legitimately 404s.
Type of Change
Testing
8 new tests.
reports an absent object as no metadata rather than throwingverified red before the fix and green after. Fulllib/uploadssuite: 374 passing across 31 files.Checklist
Audit of every changed line
A defect in this PR's own predicate was found and fixed (
a5b3…). The first version read the label astypeof name === 'string' ? name : code, so a non-matchingnameshort-circuited thecodecheck entirely. Azure raises aRestErrorwhosenamecarries the class and whosecodecarries the reason, soBlobNotFoundwas missed — narrower than the per-provider check it replaced.nameandcodeare now tested independently, with a regression test verified red before / green after.All three callers audited for fail-closed behavior.
getFileMetadatahas exactly 3 callers, all inauthorization.ts:{}verifyWorkspaceFileAccess(243)workspaceIdundefined'missing authorization metadata'warn → denyverifyPublicAssetWriteAccess(313)userIdundefinedverifyCopilotFileAccess(427)userIdundefinedThe third is a genuine behavior change and is called out deliberately: an absent copilot object previously threw and denied; it now takes the same path as an object that exists without metadata. That path grants only when
cloudKey.split('/')[0] === userId— strictly the caller's own namespace — so no cross-user access is reachable. Downstream, a granted read of an absent object 404s at storage and a granted delete is a no-op. No exposure either way.Other checks: no import cycle (
core/errors.tsimports nothing; providers are loaded dynamically fromcore). Provider files changed by import position only — verified by diffing afterbiome --write --unsafe. Fulllib/uploadssuite 375 passing across 31 files. Typecheck clean; the sole error (heic-convert) reproduces on untouchedorigin/main.Second audit round — container-level 404s
A high-effort review pass caught a defect the first audit missed:
NoSuchBucketandContainerNotFoundalso answer 404, so the status-only clause read them as an absent object. A deleted or misconfigured bucket would have degraded into silent "no metadata" — every file read failing closed, with theERRORthat used to alert on it now downgraded to the routine stale-keywarn. A total storage outage would have been indistinguishable from ordinary noise.Container-level labels are now excluded before the object-level check, verified red before / green after:
Known limitation, stated rather than implied solved: GCS reports both object- and bucket-level misses as a bare numeric
code: 404with no distinguishing label, so the two are not separable there. S3 is the production provider and does distinguish them.app/api/tools/s3/head-object/route.ts:86still hand-rolls its own check and was deliberately not migrated — it is a user-facing tool against user-supplied credentials that reportsexists: false, and adopting the shared predicate would turn a nonexistent bucket into an error rather than a negative result. That is a user-visible semantics change and does not belong in this PR.Suites after the fix:
lib/uploads376 passing / 31 files,app/api/files238 passing / 16 files.Third audit round — the abstraction itself
The earlier rounds kept finding defects in one place: the shared not-found predicate. That was the symptom. The cause was that
getFileMetadatare-implemented the S3 and BlobHEADcalls inline, duplicatingheadS3ObjectandheadBlobObject— including Azure's entire connection-string / shared-key branch, verbatim. Because it owned those calls, it had to interpret provider errors itself, and doing that safely across providers forced a second, stricter predicate.Deleting the duplication removes the whole problem:
getFileMetadatanow callsheadS3Object/headBlobObject, which already report absence asnull, and maps that to{}try/catchis gone — it no longer inspects errors at allhasObjectNotFoundLabel) is gone with it; one predicate again, used only by provider clients that know they just performed an object-level operationEach provider now owns its own not-found semantics and the dispatcher just maps
null → {}.Test coverage gap this exposed
Reverting the S3 not-found handling broke no test —
storage-client.test.tsmocksheadS3Object, so the seam hid whether the two layers agree. Three tests were added to the S3 client's own suite exercising the realheadS3Objectover a mocked SDK: absent object →null,NoSuchBucket→ raises,AccessDenied→ raises. Reverting the handling now failsreports an absent object as null rather than raising, as it should.An earlier attempt at this used
vi.resetModules()+vi.doMock()+await import(); that is banned by CLAUDE.md's testing rules and did not work regardless (the file-level mock shadows the real module). Replaced withvi.hoisted()+vi.mock()+ static imports, matching the file it lives in.Final:
lib/uploads380 passing / 31 files,app/api/files238 passing / 16 files, biome clean on all 8 touched files, typecheck clean apart from the pre-existingheic-convert.Completing the fix: the other two thirds
The change above removes one of the three ERROR lines a stale key produces. Production over 24h shows they fire in near-lockstep, because all three come from the same request:
Error verifying workspace file accessError downloading from cloud storage:Error serving file:Once authorization correctly denies, the route throws
FileNotFoundError; the inner handler logs it aterrorand rethrows, and the outer handler logs it aterroragain. So an ordinary 404 was reported twice as a server fault — androute.tsmade that plain:The precedent for the right behaviour sits a few lines above:
DocCompileUserErroris already logged atinfowith a comment noting it is "not a server fault".FileNotFoundErrornow gets the same treatment, via onelogServeFailurehelper shared by all five catch sites in the file —erroris reserved for failures that really are the server's.Nothing is silenced: the 404 is still recorded, still carries its reason, and every non-
FileNotFoundErrorstill logs aterror. Two tests cover both directions, verified red before / green after.With this, the module's ~823 ERROR lines/day drop to ~0 for the stale-key case, rather than the ~549 that would have remained.
Suites:
app/api/files240 passing / 16 files,lib/uploads380 passing / 31 files.Per-provider verification
headS3Object;NoSuchBucketnow raises rather than reading as absenceContainerNotFoundnow raisesgetFileMetadatastill raisesgetFileMetadatareturns{}as before; gains the serve-route log levelAll three cloud providers now assert absence and non-404 rethrow, each mutation-verified: removing the guard fails exactly one test in S3 and one in Blob. Blob had no not-found coverage at all before this, and it is the one provider that puts the reason in
coderather thanname, so the container-level exclusion was the least-verified path in the change.headS3Object/headBlobObjectare also reached throughheadObject, which serves the TikTok upload tool, workspace forking (2 sites), andworkspace-file-manager(2 sites). Raising on a missing bucket brings those call sites into line withheadObject's documented contract — "Returns … null when missing. Throws on errors other than 'not found'" — which returningnullfor a missing bucket had violated, sending callers into a doomed path instead of failing fast.Two narrow behaviour changes, neither reachable from current callers: a
containerName-only custom config is no longer honoured in the Blob branch (every call site passesundefined), andabortMultipartUploadnow emits awarnon a missing container where it was previously silent.