Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions apps/backend/src/routes/submissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,11 +543,19 @@ const validateUploadedFileOrReject = async (params: {
return { ok: false, error: 'mimeType is inconsistent with file extension' };
}

let metadata: { contentLength: number | null; contentType: string | null };
try {
metadata = await getObjectMetadata(env.S3_BUCKET_SUBMISSIONS, s3Key);
} catch {
return { ok: false, error: 'Uploaded file not found' };
let metadata: { contentLength: number | null; contentType: string | null } | null = null;

for (let attempt = 0; attempt < 5; attempt++) {
try {
metadata = await getObjectMetadata(env.S3_BUCKET_SUBMISSIONS, s3Key);
break;
} catch {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
Comment thread
f0reachARR marked this conversation as resolved.

if (!metadata) {
return { ok: false, error: 'Failed to retrieve uploaded file metadata' };
}

return validateUploadedFileReference({
Expand Down
67 changes: 2 additions & 65 deletions apps/backend/src/services/storage.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { buildRuleKey, buildVersionedSubmissionKey, getObjectMetadata } from './storage.js';
import { describe, expect, it } from 'vitest';
import { buildRuleKey, buildVersionedSubmissionKey } from './storage.js';

describe('storage key builders', () => {
it('builds versioned submission key with v prefix', () => {
Expand All @@ -20,67 +20,4 @@ describe('storage key builders', () => {
expect(key).toContain('rules/ed-1/');
expect(key.endsWith('_rule_doc.pdf')).toBe(true);
});

it('retries metadata lookup until the object becomes visible', async () => {
const fetchMetadata = vi
.fn<
(
bucket: string,
key: string,
) => Promise<{ contentLength: number | null; contentType: string | null }>
>()
.mockRejectedValueOnce(
Object.assign(new Error('Not found yet'), {
name: 'NotFound',
$metadata: { httpStatusCode: 404 },
}),
)
.mockResolvedValueOnce({
contentLength: 1024,
contentType: null,
})
.mockResolvedValueOnce({
contentLength: 1024,
contentType: 'application/pdf',
});
const sleep = vi.fn(async (_ms: number) => {});

const metadata = await getObjectMetadata('bucket', 'key', {
maxAttempts: 3,
retryDelayMs: 10,
fetchMetadata,
sleep,
});

expect(metadata).toEqual({
contentLength: 1024,
contentType: 'application/pdf',
});
expect(fetchMetadata).toHaveBeenCalledTimes(3);
expect(sleep).toHaveBeenCalledTimes(2);
});

it('does not retry non-retryable metadata lookup errors', async () => {
const fetchMetadata = vi
.fn<
(
bucket: string,
key: string,
) => Promise<{ contentLength: number | null; contentType: string | null }>
>()
.mockRejectedValueOnce(new Error('Access denied'));
const sleep = vi.fn(async (_ms: number) => {});

await expect(
getObjectMetadata('bucket', 'key', {
maxAttempts: 3,
retryDelayMs: 10,
fetchMetadata,
sleep,
}),
).rejects.toThrow('Access denied');

expect(fetchMetadata).toHaveBeenCalledTimes(1);
expect(sleep).not.toHaveBeenCalled();
});
});
Comment thread
f0reachARR marked this conversation as resolved.
78 changes: 5 additions & 73 deletions apps/backend/src/services/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { randomUUID } from 'node:crypto';
import {
GetObjectCommand,
HeadObjectCommand,
type HeadObjectCommandOutput,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
Expand Down Expand Up @@ -36,18 +35,6 @@ type PresignUploadByKeyInput = {
expiresIn?: number;
};

type ObjectMetadata = {
contentLength: number | null;
contentType: string | null;
};

type GetObjectMetadataOptions = {
maxAttempts?: number;
retryDelayMs?: number;
sleep?: (ms: number) => Promise<void>;
fetchMetadata?: (bucket: string, key: string) => Promise<ObjectMetadata>;
};

const encodeName = (name: string): string => {
return name.replace(/[^a-zA-Z0-9._-]/g, '_');
};
Expand Down Expand Up @@ -142,74 +129,19 @@ export const presignDownload = async (
return { presignedUrl, expiresIn };
};

const sleep = async (ms: number): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, ms));
};

const headObject = async (bucket: string, key: string): Promise<HeadObjectCommandOutput> => {
return await s3.send(
export const getObjectMetadata = async (
bucket: string,
key: string,
): Promise<{ contentLength: number | null; contentType: string | null }> => {
const response = await s3.send(
new HeadObjectCommand({
Bucket: bucket,
Key: key,
}),
);
};

const toObjectMetadata = (response: HeadObjectCommandOutput): ObjectMetadata => {
return {
contentLength: response.ContentLength ?? null,
contentType: response.ContentType ?? null,
};
};
Comment thread
f0reachARR marked this conversation as resolved.

const hasCompleteObjectMetadata = (metadata: ObjectMetadata): boolean => {
return metadata.contentLength !== null && metadata.contentType !== null;
};

const isRetryableMetadataLookupError = (error: unknown): boolean => {
if (!(error instanceof Error)) {
return false;
}

const metadata = (error as Error & { $metadata?: { httpStatusCode?: number } }).$metadata;
const statusCode = metadata?.httpStatusCode;
return statusCode === 404 || error.name === 'NotFound' || error.name === 'NoSuchKey';
};

export const getObjectMetadata = async (
bucket: string,
key: string,
options: GetObjectMetadataOptions = {},
): Promise<ObjectMetadata> => {
const maxAttempts = options.maxAttempts ?? 5;
const retryDelayMs = options.retryDelayMs ?? 1000;
const wait = options.sleep ?? sleep;
const fetchMetadata =
options.fetchMetadata ??
(async (innerBucket: string, innerKey: string): Promise<ObjectMetadata> => {
return toObjectMetadata(await headObject(innerBucket, innerKey));
});

let lastIncompleteMetadata: ObjectMetadata | null = null;

for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const metadata = await fetchMetadata(bucket, key);
if (hasCompleteObjectMetadata(metadata)) {
return metadata;
}

lastIncompleteMetadata = metadata;
} catch (error) {
if (!isRetryableMetadataLookupError(error) || attempt === maxAttempts) {
throw error;
}
}

if (attempt < maxAttempts) {
await wait(retryDelayMs);
}
}

return lastIncompleteMetadata ?? { contentLength: null, contentType: null };
};
Loading