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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [3.6.0] - 2026-10-09

No behavior change for existing users: without the new options the client behaves exactly as in 3.5.x.

### Added

- **`retryAttempts` in `ConnectionDefinition`**
- Optional total number of attempts per request, including the first one (default `3`, `1` = no retry)
- Validated in the `AppSheetClient` constructor: must be an integer >= 1, otherwise a `ValidationError` is thrown
- Replaces the previously hardcoded 3 attempts; passed through `AppSheetClientFactory` unchanged
- **`retryWrites` in `ConnectionDefinition`**
- Optional boolean, default `true` (mutations are retried as before)
- With `false`, `add`, `update` and `delete` are sent exactly once and network errors, timeouts and 5xx are thrown immediately as `NetworkError` / `AppSheetError`
- Recommended for mutations: they are not idempotent, so a retry after a timeout or 5xx can write twice (duplicate rows, a change applied again)
- Reads (`find`, `findAll`, `findOne`) are always retried, bounded by `retryAttempts`
- A non-boolean value throws a `ValidationError` in the constructor

### Changed

- `AppSheetConfig.retryAttempts` (deprecated since 3.0.0) was never evaluated; its documentation now says so and points to the `ConnectionDefinition` options

## [3.5.0] - 2026-03-12

### Added
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ All errors extend `AppSheetError` with specific subtypes:
- `RateLimitError` (429)
- `NetworkError` (no response)

Retry logic applies to network errors and 5xx server errors (max 3 attempts by default).
Retry logic applies to network errors, timeouts and 5xx server errors, up to `ConnectionDefinition.retryAttempts` total attempts (default 3, `1` = no retry). Mutations (`add`, `update`, `delete`) are retried by default as before; set `ConnectionDefinition.retryWrites: false` (recommended) to send them exactly once, since they are not idempotent and a retry after a timeout can write twice. Reads are always retried.

## AppSheet API Details

Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,25 @@ await client.updateOne('Users', { id: '123', name: 'John Updated' });
await client.deleteOne('Users', { id: '123' });
```

## Retries

Network errors, timeouts and 5xx responses are retried with exponential backoff (1 s, 2 s, ... max 10 s). Both options live in the `ConnectionDefinition`:

- `retryAttempts` - total attempts including the first one (default `3`, `1` = no retry, integer >= 1)
- `retryWrites` - retry `add`, `update` and `delete` (default `true`, unchanged behavior). Mutations are not idempotent: a retry after a timeout or 5xx can write twice. Set `retryWrites: false` (recommended) to send mutations exactly once. `find` is always retried.

In YAML/JSON schemas use literal values (`true`/`false`, numbers), not `${ENV}` placeholders: the schema loader delivers those as strings, which results in a `ValidationError`.

```typescript
const connectionDef = {
appId: '...',
applicationAccessKey: '...',
retryAttempts: 3,
retryWrites: false,
tables: { /* ... */ },
};
```

## Multi-Instance Support

Manage multiple AppSheet apps in one project:
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@techdivision/appsheet",
"version": "3.5.1",
"version": "3.6.0",
"description": "Generic TypeScript library for AppSheet CRUD operations",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
89 changes: 81 additions & 8 deletions src/client/AppSheetClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,47 @@ import {
} from '../types';
import { SelectorBuilder } from '../utils/SelectorBuilder';

const DEFAULT_RETRY_ATTEMPTS = 3;

/** Total attempts for a request that must be sent exactly once. */
const SINGLE_ATTEMPT = 1;

/**
* Validates the configured attempt count and applies the default.
*
* Fails at construction time so that a misconfiguration surfaces immediately and
* not as a silently skipped request loop on the first call.
*/
function resolveRetryAttempts(configured: number | undefined): number {
if (configured === undefined) {
return DEFAULT_RETRY_ATTEMPTS;
}
if (!Number.isInteger(configured) || configured < 1) {
throw new ValidationError(
`Invalid retryAttempts: expected an integer >= 1 (total attempts including the first), got ${String(configured)}`
);
}
return configured;
}

/**
* Validates the optional `retryWrites` flag.
*
* A truthy non-boolean (e.g. the string "false" from an env file) would silently
* keep write retries on, which is the opposite of what the caller meant.
*/
function resolveRetryWrites(configured: unknown): boolean {
if (configured === undefined) {
return true;
}
if (typeof configured !== 'boolean') {
throw new ValidationError(
`Invalid retryWrites: expected a boolean, got ${typeof configured} (${String(configured)})`
);
}
return configured;
}

/**
* AppSheet API client for performing CRUD operations on AppSheet tables.
*
Expand Down Expand Up @@ -70,6 +111,7 @@ export class AppSheetClient implements AppSheetClientInterface {
private readonly connectionDef: ConnectionDefinition;
private readonly runAsUserEmail: string;
private readonly retryAttempts: number;
private readonly retryWrites: boolean;
private readonly selectorBuilder: SelectorBuilderInterface;

/**
Expand All @@ -78,6 +120,8 @@ export class AppSheetClient implements AppSheetClientInterface {
* @param connectionDef - Full connection definition including app credentials and table schemas
* @param runAsUserEmail - Email of the user to execute all operations as (required)
* @param selectorBuilder - Optional custom SelectorBuilder for DI/AOP extensibility (defaults to SelectorBuilder)
* @throws {ValidationError} If `connectionDef.retryAttempts` is not an integer >= 1
* or `connectionDef.retryWrites` is not a boolean
*
* @example
* ```typescript
Expand All @@ -98,7 +142,8 @@ export class AppSheetClient implements AppSheetClientInterface {
) {
this.connectionDef = connectionDef;
this.runAsUserEmail = runAsUserEmail;
this.retryAttempts = 3; // Default retry attempts
this.retryAttempts = resolveRetryAttempts(connectionDef.retryAttempts);
this.retryWrites = resolveRetryWrites(connectionDef.retryWrites);
this.selectorBuilder = selectorBuilder ?? new SelectorBuilder();

// Apply defaults
Expand All @@ -119,6 +164,9 @@ export class AppSheetClient implements AppSheetClientInterface {
/**
* Add (Create) one or more rows to a table.
*
* Retried like Find unless `ConnectionDefinition.retryWrites` is `false`; see there
* for why disabling is recommended for mutations.
*
* @template T - The type of the rows being added
* @param options - Options for the add operation
* @returns Promise resolving to the created rows with server-generated fields
Expand Down Expand Up @@ -147,7 +195,7 @@ export class AppSheetClient implements AppSheetClientInterface {
Rows: options.rows,
};

const response = await this.request<ApiResponse<T>>(url, payload);
const response = await this.request<ApiResponse<T>>(url, payload, this.writeAttempts);

return {
rows: response.Rows || [],
Expand All @@ -158,6 +206,9 @@ export class AppSheetClient implements AppSheetClientInterface {
/**
* Find (Read) rows from a table with optional filtering.
*
* Network errors, timeouts and 5xx responses are retried up to
* `ConnectionDefinition.retryAttempts` total attempts (default 3).
*
* @template T - The type of the rows being retrieved
* @param options - Options for the find operation
* @returns Promise resolving to the found rows
Expand Down Expand Up @@ -194,7 +245,7 @@ export class AppSheetClient implements AppSheetClientInterface {
Rows: [],
};

const response = await this.request<ApiResponse<T>>(url, payload);
const response = await this.request<ApiResponse<T>>(url, payload, this.retryAttempts);

return {
rows: response.Rows || [],
Expand All @@ -205,6 +256,9 @@ export class AppSheetClient implements AppSheetClientInterface {
/**
* Update (Edit) one or more rows in a table.
*
* Retried like Find unless `ConnectionDefinition.retryWrites` is `false`; see there
* for why disabling is recommended for mutations.
*
* Rows must include the key field (primary key) to identify which row to update.
*
* @template T - The type of the rows being updated
Expand Down Expand Up @@ -235,7 +289,7 @@ export class AppSheetClient implements AppSheetClientInterface {
Rows: options.rows,
};

const response = await this.request<ApiResponse<T>>(url, payload);
const response = await this.request<ApiResponse<T>>(url, payload, this.writeAttempts);

return {
rows: response.Rows || [],
Expand All @@ -246,6 +300,9 @@ export class AppSheetClient implements AppSheetClientInterface {
/**
* Delete one or more rows from a table.
*
* Retried like Find unless `ConnectionDefinition.retryWrites` is `false`; see there
* for why disabling is recommended for mutations.
*
* Rows must include the key field (primary key) to identify which row to delete.
*
* @template T - The type of the rows being deleted
Expand Down Expand Up @@ -276,7 +333,7 @@ export class AppSheetClient implements AppSheetClientInterface {
Rows: options.rows,
};

const response = await this.request<ApiResponse<T>>(url, payload);
const response = await this.request<ApiResponse<T>>(url, payload, this.writeAttempts);

return {
success: true,
Expand Down Expand Up @@ -417,11 +474,20 @@ export class AppSheetClient implements AppSheetClientInterface {
/**
* Execute request with retry logic and error handling.
*
* The caller states the total attempts allowed: Find is always safe to repeat,
* whereas a repeated Add/Edit/Delete after a lost response could duplicate or
* double-apply a change, so mutations may opt out via `retryWrites`.
*
* Handles both response formats from AppSheet API:
* - Standard format: { Rows: [...], Warnings?: [...] }
* - Direct array format: [...]
*/
private async request<T>(url: string, payload: any, attempt = 1): Promise<T> {
private async request<T>(
url: string,
payload: any,
maxAttempts: number,
attempt = 1
): Promise<T> {
try {
const response = await this.axios.post<T>(url, payload);

Expand All @@ -440,13 +506,13 @@ export class AppSheetClient implements AppSheetClientInterface {

// Retry on network errors or 5xx server errors
if (
attempt < this.retryAttempts &&
attempt < maxAttempts &&
(this.isRetryableError(axiosError) || this.isServerError(axiosError))
) {
// Exponential backoff
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
await this.sleep(delay);
return this.request<T>(url, payload, attempt + 1);
return this.request<T>(url, payload, maxAttempts, attempt + 1);
}

// Convert to appropriate error type
Expand All @@ -458,6 +524,13 @@ export class AppSheetClient implements AppSheetClientInterface {
}
}

/**
* Total attempts for add/update/delete.
*/
private get writeAttempts(): number {
return this.retryWrites ? this.retryAttempts : SINGLE_ATTEMPT;
}

/**
* Check if error is retryable (network error)
*/
Expand Down
7 changes: 6 additions & 1 deletion src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ export interface AppSheetConfig {
/** Request timeout in milliseconds (default: 30000) */
timeout?: number;

/** Number of retry attempts for failed requests (default: 3) */
/**
* Not evaluated by the client.
*
* Retries are configured via `ConnectionDefinition.retryAttempts` and
* `ConnectionDefinition.retryWrites`.
*/
retryAttempts?: number;

/** Optional email of user to run all operations as (can be overridden per operation) */
Expand Down
27 changes: 27 additions & 0 deletions src/types/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,33 @@ export interface ConnectionDefinition {
/** Optional request timeout */
timeout?: number;

/**
* Optional total number of attempts per request, including the first one.
*
* Defaults to 3; `1` disables retries. Must be an integer >= 1, otherwise the
* client constructor throws a `ValidationError`. Applies to reads, and to
* mutations as long as {@link ConnectionDefinition.retryWrites} is not `false`.
*
* @default 3
*/
retryAttempts?: number;

/**
* Optional switch for retrying mutations (`add`, `update`, `delete`).
*
* Defaults to `true`, which keeps the behavior of earlier versions. Reads (`find`
* and everything built on it) are always retried regardless of this flag.
*
* Mutations are not idempotent: if a request timed out or failed with 5xx after
* AppSheet had already processed it, a retry writes twice (duplicate rows, a
* change applied again). Setting `false` is therefore recommended for mutations;
* they are then sent exactly once and the error is thrown immediately. Must be a
* boolean, otherwise the client constructor throws a `ValidationError`.
*
* @default true
*/
retryWrites?: boolean;

/** Optional global user email for all operations on this connection */
runAsUserEmail?: string;

Expand Down
Loading
Loading