Skip to content
Draft
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: 1 addition & 1 deletion src/commands/contacts/update-topics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Topics not included in the array are left unchanged.`,
// contactIdentifier's result is directly assignable: UpdateContactTopicsBaseOptions
// uses optional { id?, email? } (not a discriminated union).
const data = await withSpinner(
'Updating topic subscriptions...',
{ loading: 'Updating topic subscriptions...' },
() => resend.contacts.topics.update({ ...contactIdentifier(id), topics }),
'update_topics_error',
globalOpts,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/emails/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export const batchCommand = new Command('batch')
}

const batchData = await withSpinner(
'Sending batch...',
{ loading: 'Sending batch...' },
() => {
const options = {
...(opts.idempotencyKey && { idempotencyKey: opts.idempotencyKey }),
Expand Down
2 changes: 1 addition & 1 deletion src/commands/emails/receiving/attachment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const getAttachmentCommand = new Command('attachment')
const resend = await requireClient(globalOpts);

const data = await withSpinner(
'Fetching attachment...',
{ loading: 'Fetching attachment...', retryTransient: true },
() =>
resend.emails.receiving.attachments.get({ emailId, id: attachmentId }),
'fetch_error',
Expand Down
4 changes: 3 additions & 1 deletion src/commands/emails/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,9 @@ export const sendCommand = new Command('send')
}

const data = await withSpinner(
opts.scheduledAt ? 'Scheduling email...' : 'Sending email...',
{
loading: opts.scheduledAt ? 'Scheduling email...' : 'Sending email...',
},
() =>
resend.emails.send(
payload,
Expand Down
1 change: 1 addition & 0 deletions src/commands/templates/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Publishing an already-published template re-publishes it with the latest draft c
sdkCall: (resend) => resend.templates.publish(id),
errorCode: 'publish_error',
successMsg: `Template published: ${id}`,
retryTransient: true,
},
globalOpts,
);
Expand Down
1 change: 1 addition & 0 deletions src/commands/templates/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export const updateTemplateCommand = new Command('update')
}),
errorCode: 'update_error',
successMsg: `Template updated: ${id}`,
retryTransient: true,
},
globalOpts,
);
Expand Down
35 changes: 6 additions & 29 deletions src/lib/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,6 @@ type SdkCall<T> = (
resend: Resend,
) => Promise<{ data: T | null; error: { message: string } | null }>;

/**
* Shared pattern for all get commands:
* requireClient → withSpinner(fetch_error) → if/else output
*/
export async function runGet<T>(
config: {
loading: string;
Expand All @@ -29,7 +25,7 @@ export async function runGet<T>(
: undefined;
const resend = await requireClient(globalOpts, clientOpts);
const data = await withSpinner(
config.loading,
{ loading: config.loading, retryTransient: true },
() => config.sdkCall(resend),
'fetch_error',
globalOpts,
Expand All @@ -41,10 +37,6 @@ export async function runGet<T>(
}
}

/**
* Shared pattern for all delete commands:
* requireClient → confirmDelete (if needed) → withSpinner → if/else output
*/
export async function runDelete(
id: string,
skipConfirm: boolean,
Expand All @@ -66,7 +58,7 @@ export async function runDelete(
await confirmDelete(id, config.confirmMessage, globalOpts);
}
await withSpinner(
config.loading,
{ loading: config.loading },
() => config.sdkCall(resend),
'delete_error',
globalOpts,
Expand All @@ -81,10 +73,6 @@ export async function runDelete(
}
}

/**
* Shared pattern for create commands:
* requireClient → withSpinner('create_error') → if/else output
*/
export async function runCreate<T>(
config: {
loading: string;
Expand All @@ -99,7 +87,7 @@ export async function runCreate<T>(
: undefined;
const resend = await requireClient(globalOpts, clientOpts);
const data = await withSpinner(
config.loading,
{ loading: config.loading },
() => config.sdkCall(resend),
'create_error',
globalOpts,
Expand All @@ -111,17 +99,13 @@ export async function runCreate<T>(
}
}

/**
* Shared pattern for write commands (update/verify/remove-segment) where
* interactive output is a single status message:
* requireClient → withSpinner(errorCode) → if/else output
*/
export async function runWrite<T>(
config: {
loading: string;
sdkCall: SdkCall<T>;
errorCode: string;
successMsg: string;
retryTransient?: boolean;
permission?: ApiKeyPermission;
},
globalOpts: GlobalOpts,
Expand All @@ -131,7 +115,7 @@ export async function runWrite<T>(
: undefined;
const resend = await requireClient(globalOpts, clientOpts);
const data = await withSpinner(
config.loading,
{ loading: config.loading, retryTransient: config.retryTransient },
() => config.sdkCall(resend),
config.errorCode,
globalOpts,
Expand All @@ -143,13 +127,6 @@ export async function runWrite<T>(
}
}

/**
* Shared pattern for all list commands:
* requireClient → withSpinner → if/else output
*
* Callers pass pagination opts (if any) via the sdkCall closure.
* The onInteractive callback handles table rendering and pagination hints.
*/
export async function runList<T>(
config: {
loading: string;
Expand All @@ -164,7 +141,7 @@ export async function runList<T>(
: undefined;
const resend = await requireClient(globalOpts, clientOpts);
const result = await withSpinner(
config.loading,
{ loading: config.loading, retryTransient: true },
() => config.sdkCall(resend),
'list_error',
globalOpts,
Expand Down
8 changes: 8 additions & 0 deletions src/lib/is-transient-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const TRANSIENT_ERROR_NAMES: ReadonlySet<string> = new Set([
'internal_server_error',
'service_unavailable',
'gateway_timeout',
]);

export const isTransientError = (name?: string): boolean =>
name != null && TRANSIENT_ERROR_NAMES.has(name);
34 changes: 22 additions & 12 deletions src/lib/spinner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import pc from 'picocolors';
import type { GlobalOpts } from './client';
import { isTransientError } from './is-transient-error';
import { errorMessage, outputError } from './output';
import { isInteractive, isUnicodeSupported } from './tty';

Expand Down Expand Up @@ -49,30 +50,39 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
* Wraps an SDK call with a loading spinner and unified error handling.
*
* The spinner is purely a loading indicator — it clears itself when the call
* completes. Callers are responsible for printing success output.
*
* Automatically retries on rate_limit_exceeded errors (HTTP 429) up to 3 times,
* using the retry-after header when available or exponential backoff (1s, 2s, 4s).
*/
type SpinnerMessages = {
readonly loading: string;
readonly retryTransient?: boolean;
};

const isRetryable = (
error: { message: string; name?: string },
retryTransient: boolean,
): boolean =>
error.name === 'rate_limit_exceeded' ||
(retryTransient && isTransientError(error.name));

const retryMessage = (error: { name?: string }, delay: number): string =>
error.name === 'rate_limit_exceeded'
? `Rate limited, retrying in ${delay}s...`
: `Server error, retrying in ${delay}s...`;

export async function withSpinner<T>(
loading: string,
messages: SpinnerMessages,
call: () => Promise<SdkResponse<T>>,
errorCode: string,
globalOpts: GlobalOpts,
): Promise<T> {
const { loading, retryTransient = false } = messages;
const spinner = createSpinner(loading, globalOpts.quiet);
try {
for (let attempt = 0; ; attempt++) {
const { data, error, headers } = await call();
if (error) {
if (attempt < MAX_RETRIES && error.name === 'rate_limit_exceeded') {
if (attempt < MAX_RETRIES && isRetryable(error, retryTransient)) {
const delay =
parseRetryDelay(headers) ?? DEFAULT_RETRY_DELAYS[attempt];
spinner.update(`Rate limited, retrying in ${delay}s...`);
spinner.update(retryMessage(error, delay));
await sleep(delay * 1000);
spinner.update(loading);
continue;
Expand Down
28 changes: 28 additions & 0 deletions tests/lib/is-transient-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { isTransientError } from '../../src/lib/is-transient-error';

describe('isTransientError', () => {
it('returns true for internal_server_error', () => {
expect(isTransientError('internal_server_error')).toBe(true);
});

it('returns true for service_unavailable', () => {
expect(isTransientError('service_unavailable')).toBe(true);
});

it('returns true for gateway_timeout', () => {
expect(isTransientError('gateway_timeout')).toBe(true);
});

it('returns false for rate_limit_exceeded', () => {
expect(isTransientError('rate_limit_exceeded')).toBe(false);
});

it('returns false for not_found', () => {
expect(isTransientError('not_found')).toBe(false);
});

it('returns false for undefined', () => {
expect(isTransientError(undefined)).toBe(false);
});
});
Loading
Loading