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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
before syncing and auto-resolves the `/browser` output folder).
- Configurable `storageRegion`, `targetFolder`, `ignore` globs, and
`concurrency`.
- Automatic `retries` (default 3) with exponential backoff on failed uploads,
deletes, listings, and Pull Zone purges.
- `dryRun` mode and a `purgeAfterUpload` toggle.
- Credentials read from environment variables or a `.env.local` file.

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ missing the build aborts before touching the network with a clear message.
| `pullZoneId` | `number \| null` | `null` | Required when `purgeAfterUpload` is true. |
| `purgeAfterUpload` | `boolean` | `true` | Purge the Pull Zone cache after a successful sync. |
| `concurrency` | `number` | `8` | Parallel uploads/deletes. |
| `retries` | `number` | `3` | Retries per failed upload/delete/list/purge (exponential backoff). `0` disables. |
| `ignore` | `string[]` | `[]` | Glob patterns to skip. Supports `**`, `*`, and literals. |
| `dryRun` | `boolean` | `false` | Compute and print the diff without writing anything. |

Expand Down
34 changes: 34 additions & 0 deletions src/bunny-client.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const sdkMock = {
Expand Down Expand Up @@ -103,6 +106,37 @@ describe('BunnyClient', () => {
expect((init as RequestInit).headers).toMatchObject({ AccessKey: 'ak' });
});

it('retries a failed upload and succeeds on a later attempt', async () => {
const dir = mkdtempSync(join(tmpdir(), 'bunny-up-'));
const file = join(dir, 'app.js');
writeFileSync(file, 'console.log(1)');
try {
let calls = 0;
sdkMock.file.upload.mockImplementation(async () => {
calls += 1;
if (calls < 2) throw new Error('503 transient');
return undefined;
});
const warnings: string[] = [];
const client = new BunnyClient({
region: 'Falkenstein',
zoneName: 'my-zone',
storagePassword: 'sp',
accountApiKey: 'ak',
retries: 2,
retryBaseDelayMs: 0,
logger: { debug: () => {}, info: () => {}, warn: (m: string) => warnings.push(m) },
});

await client.upload('app.js', file, 'deadbeef', '/');

expect(sdkMock.file.upload).toHaveBeenCalledTimes(2);
expect(warnings.some((w) => /retry/i.test(w))).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it('purgePullZone throws on a non-2xx response', async () => {
const fetchMock = vi.fn(async () => new Response('forbidden', { status: 403 }));
vi.stubGlobal('fetch', fetchMock);
Expand Down
60 changes: 46 additions & 14 deletions src/bunny-client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { createReadStream } from 'node:fs';
import { Readable } from 'node:stream';
import * as BunnyStorageSDK from '@bunny.net/storage-sdk';
import { withRetry } from './retry.js';
import { STORAGE_REGIONS, type StorageRegion } from './types.js';
import type { RemoteFile } from './types.js';

function errText(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}

export interface BunnyLogger {
debug(msg: string): void;
info(msg: string): void;
Expand All @@ -16,6 +21,10 @@ export interface BunnyClientOptions {
storagePassword: string;
accountApiKey: string | null;
logger: BunnyLogger;
/** Additional attempts on a failed network call (0 = no retry). Default 0. */
retries?: number;
/** Base backoff in ms for retries. Default 300; set 0 in tests. */
retryBaseDelayMs?: number;
}

function resolveRegion(region: StorageRegion): BunnyStorageSDK.regions.StorageRegion {
Expand All @@ -30,6 +39,8 @@ export class BunnyClient {
private readonly zone: BunnyStorageSDK.zone.StorageZone;
private readonly accountApiKey: string | null;
private readonly logger: BunnyLogger;
private readonly retries: number;
private readonly retryBaseDelayMs: number;

constructor(opts: BunnyClientOptions) {
this.zone = BunnyStorageSDK.zone.connect_with_accesskey(
Expand All @@ -39,14 +50,28 @@ export class BunnyClient {
);
this.accountApiKey = opts.accountApiKey;
this.logger = opts.logger;
this.retries = opts.retries ?? 0;
this.retryBaseDelayMs = opts.retryBaseDelayMs ?? 300;
}

private retry<T>(label: string, fn: () => Promise<T>): Promise<T> {
return withRetry(fn, {
retries: this.retries,
baseDelayMs: this.retryBaseDelayMs,
onRetry: (err, attempt) =>
this.logger.warn(`Retry ${attempt}/${this.retries} for ${label}: ${errText(err)}`),
});
}

async listAll(targetFolder: string): Promise<RemoteFile[]> {
const acc: RemoteFile[] = [];
const queue: string[] = [normalizeDir(targetFolder)];
while (queue.length > 0) {
const dir = queue.shift()!;
const entries: BunnyStorageSDK.file.StorageFile[] = await BunnyStorageSDK.file.list(this.zone, dir);
const entries: BunnyStorageSDK.file.StorageFile[] = await this.retry(
`list ${dir}`,
() => BunnyStorageSDK.file.list(this.zone, dir),
);
for (const e of entries) {
if (e.isDirectory) {
queue.push(normalizeDir(`${dir}${e.objectName}/`));
Expand All @@ -65,35 +90,42 @@ export class BunnyClient {

async upload(relPath: string, absPath: string, sha256: string, targetFolder: string): Promise<void> {
const remotePath = joinRemotePath(targetFolder, relPath);
const node = createReadStream(absPath);
const web = Readable.toWeb(node) as unknown as import('node:stream/web').ReadableStream<Uint8Array>;
// Bunny Storage doesn't infer Content-Type from extension; whatever the
// client sends is what the Pull Zone serves later. Without this, .mjs/.js/.css
// are stored as application/octet-stream and browsers reject module scripts.
const contentType = detectContentType(relPath);
await BunnyStorageSDK.file.upload(this.zone, remotePath, web, { sha256Checksum: sha256, contentType });
// Open the read stream inside the retried fn: a stream is single-use, so a
// retry must start from a fresh one.
await this.retry(`upload ${relPath}`, () => {
const node = createReadStream(absPath);
const web = Readable.toWeb(node) as unknown as import('node:stream/web').ReadableStream<Uint8Array>;
return BunnyStorageSDK.file.upload(this.zone, remotePath, web, { sha256Checksum: sha256, contentType });
});
}

async remove(relPath: string, targetFolder: string): Promise<void> {
const remotePath = joinRemotePath(targetFolder, relPath);
await BunnyStorageSDK.file.remove(this.zone, remotePath);
await this.retry(`remove ${relPath}`, () => BunnyStorageSDK.file.remove(this.zone, remotePath));
}

// Pull Zone purge has no built-in retry: the orchestrator treats a failed
// purge as success-with-warning, and re-running the deploy converges.
// Retries transient failures; if it still fails the orchestrator treats it as
// success-with-warning (the cache expires by TTL and re-running converges).
async purgePullZone(pullZoneId: number): Promise<void> {
if (!this.accountApiKey) {
throw new Error('purgePullZone called without an accountApiKey');
}
const accessKey = this.accountApiKey;
const url = `https://api.bunny.net/pullzone/${pullZoneId}/purgeCache`;
const res = await fetch(url, {
method: 'POST',
headers: { AccessKey: this.accountApiKey },
await this.retry(`purge pull zone ${pullZoneId}`, async () => {
const res = await fetch(url, {
method: 'POST',
headers: { AccessKey: accessKey },
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Pull zone purge failed: ${res.status} ${res.statusText} ${body}`.trim());
}
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Pull zone purge failed: ${res.status} ${res.statusText} ${body}`.trim());
}
}
}

Expand Down
1 change: 1 addition & 0 deletions src/deploy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ function baseOptions(overrides: Partial<DeployOptions> = {}): DeployOptions {
pullZoneId: 12345,
purgeAfterUpload: true,
concurrency: 4,
retries: 3,
ignore: [],
dryRun: false,
...overrides,
Expand Down
2 changes: 2 additions & 0 deletions src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface Deps {
zoneName: string;
storagePassword: string;
accountApiKey: string | null;
retries: number;
logger: BunnyLogger;
}) => ClientLike;
}
Expand Down Expand Up @@ -117,6 +118,7 @@ export async function runDeploy(
zoneName: options.storageZoneName,
storagePassword: secrets.storagePassword,
accountApiKey: secrets.accountApiKey,
retries: options.retries,
logger: log,
});

Expand Down
52 changes: 52 additions & 0 deletions src/retry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from 'vitest';
import { withRetry } from './retry.js';

describe('withRetry', () => {
it('returns the result without retrying when fn succeeds', async () => {
const fn = vi.fn(async () => 'ok');
const out = await withRetry(fn, { retries: 3, baseDelayMs: 0 });
expect(out).toBe('ok');
expect(fn).toHaveBeenCalledTimes(1);
});

it('retries until fn succeeds', async () => {
let calls = 0;
const fn = vi.fn(async () => {
calls += 1;
if (calls < 3) throw new Error('transient');
return 'ok';
});
const out = await withRetry(fn, { retries: 3, baseDelayMs: 0 });
expect(out).toBe('ok');
expect(fn).toHaveBeenCalledTimes(3);
});

it('rethrows the last error after exhausting retries', async () => {
const fn = vi.fn(async () => {
throw new Error('boom');
});
await expect(withRetry(fn, { retries: 2, baseDelayMs: 0 })).rejects.toThrow('boom');
expect(fn).toHaveBeenCalledTimes(3); // initial attempt + 2 retries
});

it('does not retry when retries is 0', async () => {
const fn = vi.fn(async () => {
throw new Error('boom');
});
await expect(withRetry(fn, { retries: 0, baseDelayMs: 0 })).rejects.toThrow('boom');
expect(fn).toHaveBeenCalledTimes(1);
});

it('calls onRetry with the error and the 1-based attempt number', async () => {
const onRetry = vi.fn();
let calls = 0;
const fn = async () => {
calls += 1;
if (calls < 2) throw new Error('x');
return 1;
};
await withRetry(fn, { retries: 3, baseDelayMs: 0, onRetry });
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry).toHaveBeenCalledWith(expect.any(Error), 1);
});
});
30 changes: 30 additions & 0 deletions src/retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export interface RetryOptions {
/** Number of additional attempts after the first (0 = a single attempt). */
retries: number;
/** Base backoff in ms; the wait grows exponentially. Defaults to 300. */
baseDelayMs?: number;
/** Called before each retry with the error and the 1-based attempt number. */
onRetry?: (err: unknown, attempt: number) => void;
}

function sleep(ms: number): Promise<void> {
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
}

export async function withRetry<T>(fn: () => Promise<T>, opts: RetryOptions): Promise<T> {
const retries = Math.max(0, Math.floor(opts.retries));
const baseDelayMs = opts.baseDelayMs ?? 300;
let attempt = 0;
for (;;) {
try {
return await fn();
} catch (err) {
if (attempt >= retries) throw err;
attempt += 1;
opts.onRetry?.(err, attempt);
// Exponential backoff with jitter (50–100% of the computed delay).
const backoff = baseDelayMs * 2 ** (attempt - 1);
await sleep(backoff * (0.5 + Math.random() * 0.5));
}
}
}
6 changes: 6 additions & 0 deletions src/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@
"minimum": 1,
"description": "Maximum number of parallel uploads and deletes."
},
"retries": {
"type": "number",
"default": 3,
"minimum": 0,
"description": "Times to retry a failed upload, delete, list, or purge, with exponential backoff. 0 disables retries."
},
"ignore": {
"type": "array",
"items": { "type": "string" },
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface DeployOptions extends JsonObject {
pullZoneId: number | null;
purgeAfterUpload: boolean;
concurrency: number;
retries: number;
ignore: string[];
dryRun: boolean;
}
Expand Down
Loading