Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
41 changes: 38 additions & 3 deletions client/packages/cli/src/lib/backupDownload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ import type {
BackupDownloadResult,
BackupsManager,
} from '@instantdb/platform';
import version from '../version.js';

// node:http(s) send no User-Agent by default, and the presigned URLs sit behind
// CloudFront whose WAF blocks requests with no UA (the NoUserAgent_HEADER rule),
// returning a 403 "Request blocked" page. The browser and the CLI's fetch-based
// API calls carry a UA and pass; this raw request must set one too.
const userAgent = `instant-cli/${version}`;

export type {
BackupDownloadProgress,
Expand All @@ -32,7 +39,11 @@ function fetchStream(
): Promise<IncomingMessage> {
return new Promise((resolve, reject) => {
const get = url.startsWith('https:') ? httpsGet : httpGet;
const req = get(url, { signal }, resolve);
const req = get(
url,
{ signal, headers: { 'user-agent': userAgent } },
resolve,
);
req.on('error', reject);
});
}
Expand All @@ -44,6 +55,30 @@ function pipe(src: Readable, dst: Duplex): Duplex {
return src.pipe(dst);
}

// S3/CloudFront answer a rejected presigned URL with a short XML body naming
// the actual cause (SignatureDoesNotMatch, AccessDenied, expired, …). Read a
// bounded prefix so the error surfaces the reason instead of a bare status.
async function readErrorBody(res: IncomingMessage): Promise<string> {
try {
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of res) {
chunks.push(chunk as Buffer);
total += (chunk as Buffer).length;
if (total >= 2048) break;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return Buffer.concat(chunks)
.toString('utf8')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 500);
} catch {
return '';
} finally {
res.destroy();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

// Fetches a presigned URL with node:http(s), decompressing explicitly: the
// entity shards are served with `Content-Encoding: zstd` and Node doesn't
// auto-decompress that. downloadBackupToFile refuses to run without zstd
Expand All @@ -54,8 +89,8 @@ async function fetchBody(
): Promise<ReadableStream<Uint8Array>> {
const res = await fetchStream(url, signal);
if (res.statusCode !== 200) {
res.resume();
throw new Error(`HTTP ${res.statusCode}`);
const body = await readErrorBody(res);
throw new Error(`HTTP ${res.statusCode}${body ? ` — ${body}` : ''}`);
}
const encoding = res.headers['content-encoding'];
let stream: Readable = res;
Expand Down
130 changes: 130 additions & 0 deletions client/packages/platform/__tests__/src/backupDownload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,135 @@ describe('downloadBackupArchive', () => {
await expect(promise).rejects.toMatchObject({ name: 'AbortError' });
});

test('fetches later entries while an earlier one is still being written', async () => {
const files = [
{ name: 'config.json', size: 1 },
{ name: 'entities/a.jsonl', size: 1 },
{ name: 'entities/b.jsonl', size: 1 },
];
const started: string[] = [];
let resolveAllStarted!: () => void;
const allStarted = new Promise<void>((r) => {
resolveAllStarted = r;
});

const manager = {
listFiles: async () => files,
getFileUrl,
// A fetch records that it started and, once every entry's fetch has
// begun, releases the writer below.
streamStorageFiles: async function* () {},
} as any;
const trackingFetch = async (url: string) => {
started.push(url);
if (started.length === files.length) resolveAllStarted();
return bodyOf(`body:${url}`);
};

// The first entry's write can't finish until every fetch has started. A
// strictly sequential downloader would deadlock — the second file's fetch
// would wait on the first file's write, which waits on all fetches — so
// this test only completes because later fetches run ahead of the writer.
let firstAdd = true;
const createWriter = async (sink: WritableStream<Uint8Array>) => {
const w = sink.getWriter();
return {
add: async (_name: string, input: ReadableStream<Uint8Array>) => {
if (firstAdd) {
firstAdd = false;
await allStarted;
}
for await (const _chunk of input) {
// drain
}
await w.write(new Uint8Array([0]));
},
close: () => w.close(),
};
};

await downloadBackupArchive({
manager,
backup,
fetchBody: trackingFetch,
sink: nullSink(),
createWriter,
});

expect(started).toEqual([
'config.json',
'entities/a.jsonl',
'entities/b.jsonl',
]);
});

test('retries a transient failure opening an entry body', async () => {
const names: string[] = [];
const manager = {
listFiles,
getFileUrl,
streamStorageFiles: async function* () {
yield { locationId: 'loc-1', path: 'a.png', url: 'loc-1-url' };
},
} as any;

// The storage blob's first open fails once, then succeeds.
let storageAttempts = 0;
const flakyFetch = async (url: string) => {
if (url === 'loc-1-url') {
storageAttempts++;
if (storageAttempts === 1) throw new Error('ECONNRESET');
}
return bodyOf(`body:${url}`);
};

const result = await downloadBackupArchive({
manager,
backup,
fetchBody: flakyFetch,
sink: nullSink(),
createWriter: makeWriter(names),
retry: { attempts: 3, delayMs: 0 },
});

expect(storageAttempts).toBe(2);
expect(names).toEqual([
'config.json',
'entities/todos.jsonl',
'files/loc-1',
]);
expect(result.files).toBe(1);
});

test('gives up after exhausting retries and names the failing entry', async () => {
const manager = {
listFiles,
getFileUrl,
streamStorageFiles: async function* () {
yield { locationId: 'loc-9', path: null, url: 'bad-url' };
},
} as any;

let attempts = 0;
await expect(
downloadBackupArchive({
manager,
backup,
fetchBody: async (url: string) => {
if (url === 'bad-url') {
attempts++;
throw new Error('HTTP 500');
}
return bodyOf('x');
},
sink: nullSink(),
createWriter: makeWriter([]),
retry: { attempts: 3, delayMs: 0 },
}),
).rejects.toThrow('Couldn\'t download storage file "loc-9" (HTTP 500).');
expect(attempts).toBe(3);
});

test('names a pathless storage file by locationId when its download fails', async () => {
const manager = {
listFiles,
Expand All @@ -187,6 +316,7 @@ describe('downloadBackupArchive', () => {
},
sink: nullSink(),
createWriter: makeWriter([]),
retry: { attempts: 1 },
}),
).rejects.toThrow('Couldn\'t download storage file "loc-9" (HTTP 500).');
});
Expand Down
Loading
Loading