Skip to content

Commit 48133e9

Browse files
committed
fix(uploads): require an object-level label before treating a lookup 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.
1 parent b9ef75e commit 48133e9

3 files changed

Lines changed: 67 additions & 25 deletions

File tree

apps/sim/lib/uploads/core/errors.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { isObjectNotFoundError } from '@/lib/uploads/core/errors'
5+
import { hasObjectNotFoundLabel, isObjectNotFoundError } from '@/lib/uploads/core/errors'
66

77
describe('isObjectNotFoundError', () => {
88
it('matches the shapes each storage provider uses for a missing object', () => {
@@ -67,3 +67,23 @@ describe('isObjectNotFoundError', () => {
6767
expect(isObjectNotFoundError(404)).toBe(false)
6868
})
6969
})
70+
71+
describe('hasObjectNotFoundLabel', () => {
72+
it('accepts only a provider that names the object as the missing resource', () => {
73+
expect(hasObjectNotFoundLabel({ name: 'NotFound' })).toBe(true)
74+
expect(hasObjectNotFoundLabel({ name: 'NoSuchKey' })).toBe(true)
75+
expect(hasObjectNotFoundLabel({ name: 'RestError', code: 'BlobNotFound' })).toBe(true)
76+
})
77+
78+
it('rejects an unlabelled 404, which cannot be attributed to the object', () => {
79+
/** GCS answers a missing object and a missing bucket identically. */
80+
expect(hasObjectNotFoundLabel({ code: 404 })).toBe(false)
81+
expect(hasObjectNotFoundLabel({ statusCode: 404 })).toBe(false)
82+
expect(hasObjectNotFoundLabel({ $metadata: { httpStatusCode: 404 } })).toBe(false)
83+
})
84+
85+
it('rejects a missing bucket or container', () => {
86+
expect(hasObjectNotFoundLabel({ name: 'NoSuchBucket' })).toBe(false)
87+
expect(hasObjectNotFoundLabel({ name: 'RestError', code: 'ContainerNotFound' })).toBe(false)
88+
})
89+
})

apps/sim/lib/uploads/core/errors.ts

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,37 +7,55 @@ const OBJECT_NOT_FOUND_LABELS = new Set(['NotFound', 'NoSuchKey', 'BlobNotFound'
77
*/
88
const CONTAINER_NOT_FOUND_LABELS = new Set(['NoSuchBucket', 'ContainerNotFound'])
99

10+
function readLabels(error: unknown): string[] | null {
11+
if (!error || typeof error !== 'object') return null
12+
const { name, code } = error as { name?: unknown; code?: unknown }
13+
/**
14+
* `name` and `code` are both consulted: Azure raises a `RestError` whose `name`
15+
* carries the class and whose `code` carries the reason, while the AWS SDK puts
16+
* the reason in `name`.
17+
*/
18+
return [name, code].filter((value): value is string => typeof value === 'string')
19+
}
20+
21+
/**
22+
* True when a provider names the missing resource as the object itself.
23+
*
24+
* The strict form. Use it wherever the caller cannot vouch for what was requested,
25+
* because an unlabelled 404 is ambiguous: GCS answers a missing object and a
26+
* missing bucket identically (`code: 404`, `errors[].reason: 'notFound'`), so only
27+
* the human-readable message separates them. Treating that as absence would let a
28+
* bucket misconfiguration read as "no metadata" and fail every read closed with
29+
* nothing left to alert on, so it stays an error here.
30+
*/
31+
export function hasObjectNotFoundLabel(error: unknown): boolean {
32+
const labels = readLabels(error)
33+
if (!labels) return false
34+
if (labels.some((label) => CONTAINER_NOT_FOUND_LABELS.has(label))) return false
35+
return labels.some((label) => OBJECT_NOT_FOUND_LABELS.has(label))
36+
}
37+
1038
/**
11-
* True when a storage provider reports that an object simply does not exist.
39+
* True when a storage provider reports that an object does not exist.
1240
*
13-
* Absence is an expected outcome of a lookup, not a failure, so callers turn this
14-
* into an empty result rather than propagating it. Every provider spells it
15-
* differently — S3 throws `NotFound` (HeadObject) or `NoSuchKey` (GetObject),
16-
* Azure Blob throws `BlobNotFound`, and GCS throws a numeric `code: 404` — and the
17-
* status may arrive as `$metadata.httpStatusCode`, `statusCode`, or `code`.
41+
* The lenient form, for a caller that has just performed an object-level operation
42+
* and can therefore attribute a bare 404 to that object — which is what every
43+
* provider client here does, and how each spelled this check before it was shared.
44+
* It additionally accepts a bare 404 (`code`, `statusCode`, or
45+
* `$metadata.httpStatusCode`), which GCS relies on since it carries no label.
1846
*
19-
* Only genuine absence matches. A network failure, a permission denial, or a
20-
* provider 5xx still propagates so it surfaces as the error it is.
47+
* A network failure, a permission denial, or a provider 5xx still propagates.
2148
*/
2249
export function isObjectNotFoundError(error: unknown): boolean {
23-
if (!error || typeof error !== 'object') return false
50+
const labels = readLabels(error)
51+
if (!labels) return false
52+
if (labels.some((label) => CONTAINER_NOT_FOUND_LABELS.has(label))) return false
53+
if (labels.some((label) => OBJECT_NOT_FOUND_LABELS.has(label))) return true
2454

25-
const { name, code, statusCode, $metadata } = error as {
26-
name?: unknown
55+
const { code, statusCode, $metadata } = error as {
2756
code?: unknown
2857
statusCode?: unknown
2958
$metadata?: { httpStatusCode?: unknown }
3059
}
31-
32-
/**
33-
* `name` and `code` are both consulted: Azure raises a `RestError` whose `name`
34-
* carries the class and whose `code` carries the reason, while the AWS SDK puts
35-
* the reason in `name`.
36-
*/
37-
const labels = [name, code].filter((value): value is string => typeof value === 'string')
38-
39-
if (labels.some((label) => CONTAINER_NOT_FOUND_LABELS.has(label))) return false
40-
if (labels.some((label) => OBJECT_NOT_FOUND_LABELS.has(label))) return true
41-
4260
return code === 404 || statusCode === 404 || $metadata?.httpStatusCode === 404
4361
}

apps/sim/lib/uploads/core/storage-client.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { USE_BLOB_STORAGE, USE_GCS_STORAGE, USE_S3_STORAGE } from '@/lib/uploads/config'
2-
import { isObjectNotFoundError } from '@/lib/uploads/core/errors'
2+
import { hasObjectNotFoundLabel } from '@/lib/uploads/core/errors'
33
import type { StorageConfig } from '@/lib/uploads/shared/types'
44

55
export type { StorageConfig } from '@/lib/uploads/shared/types'
@@ -40,8 +40,12 @@ export async function getFileMetadata(
4040
* previous key finds nothing. Report it the way this function already reports
4141
* "nothing known about this key" rather than as a failure, so callers fall
4242
* through to their own not-found handling instead of an error path.
43+
*
44+
* Deliberately the labelled check: this dispatches across every provider and so
45+
* cannot attribute a bare 404 to the object rather than its bucket. An
46+
* unlabelled 404 keeps propagating, exactly as it did before.
4347
*/
44-
if (isObjectNotFoundError(error)) return {}
48+
if (hasObjectNotFoundLabel(error)) return {}
4549
throw error
4650
}
4751
}

0 commit comments

Comments
 (0)