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
73 changes: 73 additions & 0 deletions src/context/Uploader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,79 @@ describe('UploaderImpl', () => {
});
});

test('S3 PUT XML error → parses s3Code and requestId', async () => {
const xml =
'<?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code>' +
'<Message>Access Denied</Message><RequestId>ABC123XYZ</RequestId>' +
'<HostId>hostid==</HostId></Error>';
const { fetch } = recordFetch([presignResponse(), new Response(xml, { status: 403 })]);
const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch }).upload(uploadInput.build());

expect(result.isErr()).toBe(true);
const err = result._unsafeUnwrapErr();
expect(err).toMatchObject({ kind: 's3-bad-status', status: 403, s3Code: 'AccessDenied', requestId: 'ABC123XYZ' });
});

test('retries a transient S3 5xx, then succeeds', async () => {
const { fetch, calls } = recordFetch([
presignResponse(),
new Response('<Error><Code>SlowDown</Code></Error>', { status: 503 }),
new Response('', { status: 200 }),
]);
const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch, retryMinTimeoutMs: 0 }).upload(
uploadInput.build(),
);

expect(result.isOk()).toBe(true);
expect(calls.length).toBe(3); // presign + 2 PUT attempts
});

test('retries a network failure during PUT, then succeeds', async () => {
const responses = [presignResponse(), 'throw' as const, new Response('', { status: 200 })];
let calls = 0;
const fetchFn: FetchFn = () => {
const next = responses[calls++];
if (next === 'throw') return Promise.reject(new Error('econnreset'));
return Promise.resolve(next as Response);
};
const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch: fetchFn, retryMinTimeoutMs: 0 }).upload(
uploadInput.build(),
);

expect(result.isOk()).toBe(true);
expect(calls).toBe(3); // presign + failed PUT + retried PUT
});

test('does not retry a non-transient 403 from S3', async () => {
const { fetch, calls } = recordFetch([presignResponse(), new Response('access denied', { status: 403 })]);
const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch, retryMinTimeoutMs: 0 }).upload(
uploadInput.build(),
);

expect(result.isErr()).toBe(true);
expect(result._unsafeUnwrapErr().kind).toBe('s3-bad-status');
expect(calls.length).toBe(2); // presign + single PUT, no retry
});

test('exhausts attempts on a persistent transient failure', async () => {
const { fetch, calls } = recordFetch([
presignResponse(),
new Response('<Error><Code>InternalError</Code></Error>', { status: 500 }),
new Response('<Error><Code>InternalError</Code></Error>', { status: 500 }),
]);
const result = await new UploaderImpl({
endpoint: ENDPOINT,
fetch,
maxAttempts: 2,
retryMinTimeoutMs: 0,
}).upload(uploadInput.build());

expect(result.isErr()).toBe(true);
const err = result._unsafeUnwrapErr();
expect(err).toMatchObject({ kind: 's3-bad-status', status: 500, s3Code: 'InternalError' });
expect(calls.length).toBe(3); // presign + 2 PUT attempts
});

test('network failure during presign → presign-request-failed', async () => {
const fetchFn: FetchFn = () => Promise.reject(new Error('econnreset'));
const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch: fetchFn }).upload(uploadInput.build());
Expand Down
71 changes: 67 additions & 4 deletions src/context/Uploader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type UploadError =
| { kind: 'presign-bad-status'; status: number; body: string }
| { kind: 'presign-bad-response'; message: string }
| { kind: 's3-put-failed'; message: string }
| { kind: 's3-bad-status'; status: number; body: string };
| { kind: 's3-bad-status'; status: number; body: string; s3Code?: string; requestId?: string };

export interface UploadInput {
readonly bytes: Uint8Array;
Expand All @@ -31,6 +31,8 @@ export type FetchFn = (url: string, init?: RequestInit) => Promise<Response>;
interface UploaderImplOptions {
readonly endpoint?: string;
readonly fetch?: FetchFn;
readonly maxAttempts?: number;
readonly retryMinTimeoutMs?: number;
}

interface PresignResponse {
Expand All @@ -42,18 +44,37 @@ interface PresignResponse {
export class UploaderImpl implements Uploader {
readonly #endpoint: string;
readonly #fetch: FetchFn;
readonly #maxAttempts: number;
readonly #retryMinTimeoutMs: number;

constructor(options: UploaderImplOptions = {}) {
this.#endpoint = options.endpoint ?? DEFAULT_UPLOAD_ENDPOINT;
this.#fetch = options.fetch ?? fetch;
this.#maxAttempts = options.maxAttempts ?? 4;
this.#retryMinTimeoutMs = options.retryMinTimeoutMs ?? 500;
}

upload(input: UploadInput): ResultAsync<UploadResult, UploadError> {
return this.#requestPresign(input).andThen((presign) =>
this.#putToS3(presign.presignedUrl, input.bytes).map(() => ({ uploadId: presign.uploadId })),
this.#putToS3WithRetry(presign.presignedUrl, input.bytes).map(() => ({ uploadId: presign.uploadId })),
);
}

// The bytes are buffered in memory, so the PUT is safely replayable. Retry only
// the transient S3 outcomes (RequestTimeout/SlowDown/5xx, network errors) —
// see isRetryableUploadError; 4xx signature/permission failures fail fast.
// Backoff is exponential from retryMinTimeoutMs; attempts cap at maxAttempts.
#putToS3WithRetry(url: string, bytes: Uint8Array, attempt = 1): ResultAsync<void, UploadError> {
return this.#putToS3(url, bytes).orElse((err) => {
if (attempt >= this.#maxAttempts || !isRetryableUploadError(err)) {
return errAsync<void, UploadError>(err);
}
return delay(this.#retryMinTimeoutMs * 2 ** (attempt - 1)).andThen(() =>
this.#putToS3WithRetry(url, bytes, attempt + 1),
);
});
}

#requestPresign(input: UploadInput): ResultAsync<PresignResponse, UploadError> {
const body = JSON.stringify({
owner: input.owner,
Expand Down Expand Up @@ -95,7 +116,7 @@ export class UploaderImpl implements Uploader {
).andThen((res) => {
if (!res.ok) {
return ResultAsync.fromSafePromise(res.text().catch(() => '')).andThen((text) =>
errAsync<void, UploadError>({ kind: 's3-bad-status', status: res.status, body: text }),
errAsync<void, UploadError>({ kind: 's3-bad-status', status: res.status, body: text, ...parseS3Error(text) }),
);
}
return okAsync<void, UploadError>(undefined);
Expand Down Expand Up @@ -126,6 +147,48 @@ function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

const delay = (ms: number): ResultAsync<void, never> =>
ResultAsync.fromSafePromise(new Promise<void>((resolve) => setTimeout(resolve, ms)));

function isRetryableUploadError(err: UploadError): boolean {
switch (err.kind) {
case 's3-put-failed':
return true;
case 's3-bad-status':
return err.status === 429 || err.status >= 500 || err.s3Code === 'RequestTimeout';
default:
return false;
}
}

// S3 errors are XML; we pull the machine-readable <Code> and <RequestId> (cause + AWS
// support handle) but drop the body, which echoes the owner/email object key.
function parseS3Error(body: string): { s3Code?: string; requestId?: string } {
const s3Code = body.match(/<Code>([^<]+)<\/Code>/)?.[1];
const requestId = body.match(/<RequestId>([^<]+)<\/RequestId>/)?.[1];
return { ...(s3Code ? { s3Code } : {}), ...(requestId ? { requestId } : {}) };
}

// Privacy-safe telemetry: omits the raw S3 body and presigned URL (both embed the
// owner/email object key). Network error messages are safe to include.
export function uploadErrorTelemetry(err: UploadError): Record<string, string | number> {
switch (err.kind) {
case 'presign-request-failed':
case 'presign-bad-response':
case 's3-put-failed':
return { error_kind: err.kind, error_message: err.message.slice(0, 300) };
case 'presign-bad-status':
return { error_kind: err.kind, status: err.status };
case 's3-bad-status':
return {
error_kind: err.kind,
status: err.status,
...(err.s3Code ? { s3_code: err.s3Code } : {}),
...(err.requestId ? { request_id: err.requestId } : {}),
};
}
}

export function formatUploadError(err: UploadError): string {
switch (err.kind) {
case 'presign-request-failed':
Expand All @@ -137,6 +200,6 @@ export function formatUploadError(err: UploadError): string {
case 's3-put-failed':
return `failed to upload to S3: ${err.message}`;
case 's3-bad-status':
return `S3 returned ${err.status}: ${err.body || '(empty body)'}`;
return `S3 returned ${err.status}${err.s3Code ? ` (${err.s3Code})` : ''}: ${err.body || '(empty body)'}`;
}
}
7 changes: 4 additions & 3 deletions src/interactive/sharePrompt.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Context } from '../context/index.ts';
import { type Prompter, formatPromptError } from '../context/Prompter.ts';
import { formatUploadError } from '../context/Uploader.ts';
import { formatUploadError, uploadErrorTelemetry } from '../context/Uploader.ts';

const SUPPORT_LINE = 'Reach us at founders@contextbridge.ai — or learn more at https://patchwave.ai';

Expand Down Expand Up @@ -41,7 +41,7 @@ export function showLocalReportReadyNotice(inputs: ReportReadyNoticeInputs): voi
}

export async function runSharePrompt(inputs: SharePromptInputs): Promise<ShareOutcome> {
const { prompter, analytics, uploader } = inputs.context;
const { prompter, analytics, uploader, logger } = inputs.context;

prompter.note([`Scanned: ${inputs.target}`, `HTML report: ${inputs.htmlPath}`].join('\n'), 'Report ready');

Expand Down Expand Up @@ -92,7 +92,8 @@ export async function runSharePrompt(inputs: SharePromptInputs): Promise<ShareOu
if (uploadResult.isErr()) {
const message = formatUploadError(uploadResult.error);
spinner.stop('Upload failed.');
analytics.capture('upload_failed', { error_kind: uploadResult.error.kind });
analytics.capture('upload_failed', uploadErrorTelemetry(uploadResult.error));
logger.warn({ err: uploadResult.error }, 'Analysis upload failed');
prompter.error(message);
prompter.note(
[`Your local report is unchanged:`, ` ${inputs.htmlPath}`, '', SUPPORT_LINE].join('\n'),
Expand Down