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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ npm install @axionvera/pocketpay-sdk
- [React Native Compatibility](./docs/react-native.md) - Integration guide for Expo and bare React Native: polyfills, Metro config, secure storage, and known limitations
- [Transaction Date Formatting](./docs/transaction-timestamps.md) - Format of every `createdAt` timestamp returned by the SDK
- [Network Error Handling](./docs/network-errors.md) - Retry guidance for Horizon, Friendbot, and Soroban RPC failures
- [Safe Retry Policy](./docs/retry-policy.md) - Classifying submission outcomes, safe retry rules, and the `withRetryPolicy` API
- [Error Handling](./docs/error-handling.md) - SDK error handling overview
- [Logging Guidance](./docs/logging.md) - Safe logging practices for SDK applications
- [Security Best Practices](./docs/security.md) - Key management and transaction safety
Expand Down
227 changes: 227 additions & 0 deletions docs/retry-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
# Safe Retry Policy

This guide explains how to retry failed or uncertain transaction submissions without risking duplicate payments.

---

## The problem: unknown submission outcomes

When submitting a signed transaction to Stellar, the request can fail in three fundamentally different ways:

1. **Transient network error** — the request never reached Horizon (rate-limit, brief outage). The transaction has *not* been processed. Resubmitting the same envelope is safe.
2. **Definitive rejection** — Horizon received the transaction and rejected it (`tx_bad_seq`, `tx_insufficient_balance`, etc.). Resubmitting the same envelope will always fail with the same result. You must build a new transaction.
3. **Unknown outcome** — a gateway timeout (HTTP 504) or network drop occurred *while* Horizon was processing the transaction. It may have been committed to a ledger already. Resubmitting without checking first risks a **duplicate payment**.

The SDK represents these three cases as a discriminated union called `SubmissionOutcome`.

---

## SubmissionOutcome

```typescript
import type { SubmissionOutcome } from '@axionvera/pocketpay-sdk';
```

| `kind` | Meaning | Safe to resubmit same envelope? |
|---|---|---|
| `"success"` | Transaction confirmed on-chain. | N/A |
| `"retryable_failure"` | Transient error (429, 503…). Same envelope can be submitted again. | **Yes** |
| `"non_retryable_failure"` | Definitive rejection or expiry. Build a new transaction. | **No** |
| `"unknown_status"` | Timeout/network drop. Must poll Horizon before deciding. | **No — poll first** |

### Checking outcomes

```typescript
import {
classifySubmissionOutcome,
isSafeToRetry,
requiresStatusCheck,
classifySubmitError,
pollTransactionStatus,
} from '@axionvera/pocketpay-sdk';

// After a raw submission attempt:
try {
await server.submitTransaction(tx);
// success — build outcome manually
const outcome = classifySubmissionOutcome(undefined, tx.hash().toString('hex'));
} catch (rawError) {
const classified = classifySubmitError(rawError, tx.hash().toString('hex'));
const outcome = classifySubmissionOutcome(classified);

switch (outcome.kind) {
case 'retryable_failure':
// isSafeToRetry(outcome) === true
// outcome.suggestedDelayMs gives a minimum wait in ms
break;

case 'non_retryable_failure':
// Build a new transaction — the current envelope will never succeed
break;

case 'unknown_status':
// requiresStatusCheck(outcome) === true
// Poll before doing anything else
const txRecord = await pollTransactionStatus(tx, { maxPollAttempts: 10 });
break;
}
}
```

### Helper predicates

```typescript
isSafeToRetry(outcome) // true only for "retryable_failure"
requiresStatusCheck(outcome) // true only for "unknown_status"
```

---

## withRetryPolicy — automated safe retry loop

`withRetryPolicy` wraps `submitTransactionIdempotently` and applies structured exponential back-off. It respects the submission-safety contract automatically:

- **retryable_failure** → waits and retries (up to `maxAttempts` total).
- **non_retryable_failure** → throws immediately without further attempts.
- **unknown_status** → delegates to `submitTransactionIdempotently`'s internal status polling; **never blindly resubmits the envelope**.

```typescript
import { withRetryPolicy } from '@axionvera/pocketpay-sdk';

const result = await withRetryPolicy(signedTx, {
maxAttempts: 4, // includes the first attempt (default: 4)
initialBackoffMs: 1000, // delay before attempt 2 (default: 1 000 ms)
maxBackoffMs: 16000, // back-off ceiling (default: 16 000 ms)
backoffMultiplier: 2, // doubles each retry (default: 2)
jitter: true, // add randomness to avoid thundering herd (default: true)
});

console.log('Confirmed in ledger', result.ledger);
```

### RetryPolicy fields

| Field | Type | Default | Description |
|---|---|---|---|
| `maxAttempts` | `number` | `4` | Total attempts including the first. Set to `1` to disable retries. |
| `initialBackoffMs` | `number` | `1000` | Delay in ms before the second attempt. |
| `maxBackoffMs` | `number` | `16000` | Upper cap on the inter-attempt delay. |
| `backoffMultiplier` | `number` | `2` | Multiplier applied per retry (`initialBackoffMs × multiplier^n`). |
| `jitter` | `boolean` | `true` | Randomises delay in `[0, computed]` to spread retries across clients. |
| `config` | `Partial<SDKConfig>` | — | SDK config overrides forwarded to Horizon calls. |
| `onAttempt` | `(attempt, outcome, delayMs) => void` | — | Callback invoked after every failed attempt. |

### Handling exhaustion

When all attempts are consumed (or a non-retryable/unknown outcome terminates the loop early), `withRetryPolicy` throws a `PocketPayError`. The error carries an `exhaustedResult` property with full context:

```typescript
import {
withRetryPolicy,
PocketPayError,
type RetryPolicyExhaustedResult,
} from '@axionvera/pocketpay-sdk';

try {
await withRetryPolicy(signedTx, { maxAttempts: 4 });
} catch (error) {
if (error instanceof PocketPayError && (error as any).exhaustedResult) {
const { finalOutcome, attempts, error: lastError }
= (error as any).exhaustedResult as RetryPolicyExhaustedResult;

switch (finalOutcome) {
case 'non_retryable_failure':
// Transaction was definitively rejected.
// Inspect lastError.code (e.g. 'PAYMENT_FAILED') for the reason.
// Build a new transaction before retrying.
console.error(`Rejected after ${attempts} attempt(s):`, lastError.message);
break;

case 'unknown_status':
// Polling could not confirm whether the transaction landed.
// DO NOT resubmit. Check the block explorer with lastError.transactionHash.
console.error(
`Status unknown for hash ${lastError.transactionHash}. Check explorer before retrying.`,
);
break;

case 'retryable_failure':
// Transient errors persisted through all retries.
// Wait longer, then retry, or surface the failure to the user.
console.error(`Transient error persisted through ${attempts} attempt(s).`);
break;
}
}
}
```

### Progress logging with onAttempt

```typescript
await withRetryPolicy(signedTx, {
maxAttempts: 4,
onAttempt(attempt, outcome, delayMs) {
if (outcome.kind === 'retryable_failure') {
console.warn(`Attempt ${attempt} failed (${outcome.error.code}). Retrying in ${delayMs}ms…`);
}
},
});
```

---

## Outcome decision tree

```
Submission attempt
├─ Success ──────────────────────────────────► Return result ✓
├─ retryable_failure ────────────────────────► Backoff → retry
│ (429, 503, transient errors) (up to maxAttempts)
├─ non_retryable_failure ────────────────────► Throw immediately ✗
│ (PAYMENT_FAILED, TX_EXPIRED, bad_seq…) (rebuild required)
└─ unknown_status ───────────────────────────► Poll Horizon
(504, ETIMEDOUT, ECONNRESET…) │
├─ Found ──────► Return result ✓
├─ Expired ────► Throw TX_EXPIRED ✗
└─ Unresolved ► Throw TX_STATUS_UNKNOWN ✗
```

---

## No blind resubmission

The key safety guarantee is:

> **An `unknown_status` outcome is never resolved by sending the same transaction again.** Status is first confirmed via `pollTransactionStatus`. Only after the polling window closes without finding the transaction (and `TX_EXPIRED` confirms it cannot land) is it safe to build a new transaction.

This prevents double-spending in applications that retry automatically on timeout.

---

## Relationship to idempotency helpers

`withRetryPolicy` builds on top of the lower-level helpers:

| Helper | Purpose |
|---|---|
| `classifySubmitError` | Maps raw Horizon/network errors → `PocketPayError` with `code`, `retryable`, `transactionHash`. |
| `classifySubmissionOutcome` | Maps a `PocketPayError` → `SubmissionOutcome` discriminated union. |
| `isSafeToRetry` | Predicate for `retryable_failure`. |
| `requiresStatusCheck` | Predicate for `unknown_status`. |
| `submitTransactionIdempotently` | Submit + auto-poll on timeout. Used internally by `withRetryPolicy`. |
| `pollTransactionStatus` | Manual status polling by transaction hash. |
| `withRetryPolicy` | Full retry loop combining all of the above. |

See [Idempotency Strategy](./idempotency.md) for a deeper dive into the polling mechanism.

---

## When not to use withRetryPolicy

- **Simple fire-and-forget payments** that use `sendXLM` already handle errors via the standard `PocketPayError` pattern. `withRetryPolicy` is for callers that build and sign transactions manually and need fine-grained retry control.
- **Non-idempotent operations** — if your application logic is not idempotent (e.g. incrementing a counter), adding automatic retries without application-level deduplication may produce incorrect results even when the transaction hash guarantee holds at the Stellar layer.
- **High-frequency trading / automated systems** — consider implementing your own policy with a circuit breaker to avoid cascading retries under sustained Horizon degradation.
114 changes: 113 additions & 1 deletion src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export type { ResultWarning, RecoveryHint } from '../types';

// ─── Error Classification ───────────────────────────────────────────────────

import { PocketPayError } from '../types';
import { PocketPayError, SubmissionOutcome } from '../types';

/**
* Classifies raw network or Horizon submission errors into a structured `PocketPayError`
Expand Down Expand Up @@ -108,3 +108,115 @@ export function isUnknownStatusError(error: unknown): boolean {
}
return false;
}

// ─── SubmissionOutcome Classification ───────────────────────────────────────

/**
* Maps a raw submission result or error into a typed {@link SubmissionOutcome}.
*
* This is the primary entry point for categorising **what happened** after a
* Horizon submission attempt. It produces a discriminated union with four
* variants:
*
* - `"success"` — pass `txHash` and no `error`; represents a confirmed submit.
* - `"retryable_failure"` — transient errors safe to retry (e.g. rate-limit).
* - `"non_retryable_failure"` — definitive on-chain rejection; rebuild required.
* - `"unknown_status"` — timeout/network drop; must poll before any action.
*
* Callers that catch a raw error should first run it through
* {@link classifySubmitError} to obtain a `PocketPayError`, then pass that
* result here.
*
* @example
* ```ts
* try {
* await server.submitTransaction(tx);
* const outcome = classifySubmissionOutcome(undefined, tx.hash().toString('hex'));
* } catch (rawError) {
* const classified = classifySubmitError(rawError, txHash);
* const outcome = classifySubmissionOutcome(classified);
* }
* ```
*/
export function classifySubmissionOutcome(
error: PocketPayError | undefined,
txHash?: string,
): SubmissionOutcome {
// ── Success path ──────────────────────────────────────────────────────────
if (!error) {
if (!txHash) {
throw new Error(
'classifySubmissionOutcome: txHash is required when error is undefined (success path)',
);
}
return { kind: 'success', transactionHash: txHash };
}

// ── Unknown status (timeout / network drop) ───────────────────────────────
if (error.code === 'TX_STATUS_UNKNOWN') {
return {
kind: 'unknown_status',
error,
transactionHash: error.transactionHash ?? txHash,
};
}

// ── Transaction has already expired ───────────────────────────────────────
// TX_EXPIRED means validators can never accept this envelope. Treat it as a
// non-retryable failure so callers know they must rebuild, not just wait.
if (error.code === 'TX_EXPIRED') {
return { kind: 'non_retryable_failure', error };
}

// ── Retryable: rate-limit, transient network ───────────────────────────────
if (error.retryable === true) {
// Provide a sensible default backoff. For 429s the caller may use the
// Retry-After header if available; here we default to 2 s.
const suggestedDelayMs = error.statusCode === 429 ? 2_000 : 1_000;
return { kind: 'retryable_failure', error, suggestedDelayMs };
}

// ── Definitive rejection (PAYMENT_FAILED, SEND_ERROR, etc.) ───────────────
return { kind: 'non_retryable_failure', error };
}

/**
* Returns `true` when it is safe to submit the **same signed transaction
* envelope** again without first polling Horizon for its current status.
*
* Only `"retryable_failure"` outcomes qualify. Both `"unknown_status"` (must
* poll first) and `"non_retryable_failure"` (must rebuild) return `false`.
*
* @example
* ```ts
* const outcome = classifySubmissionOutcome(classified);
* if (isSafeToRetry(outcome)) {
* await delay(outcome.suggestedDelayMs);
* await submitTransactionIdempotently(tx);
* }
* ```
*/
export function isSafeToRetry(outcome: SubmissionOutcome): outcome is Extract<SubmissionOutcome, { kind: 'retryable_failure' }> {
return outcome.kind === 'retryable_failure';
}

/**
* Returns `true` when the submission outcome is `"unknown_status"`, meaning
* the SDK could not determine whether the transaction reached on-chain
* consensus. The caller **must** check transaction status via
* {@link pollTransactionStatus} before deciding whether to rebuild or accept.
*
* Returning `true` here does **not** mean a retry is safe — it means a
* status check is *required* before any further action is taken.
*
* @example
* ```ts
* const outcome = classifySubmissionOutcome(classified);
* if (requiresStatusCheck(outcome)) {
* const txRecord = await pollTransactionStatus(tx, { maxPollAttempts: 10 });
* }
* ```
*/
export function requiresStatusCheck(outcome: SubmissionOutcome): outcome is Extract<SubmissionOutcome, { kind: 'unknown_status' }> {
return outcome.kind === 'unknown_status';
}
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export type {
TrustlineStatus,
TrustlineCheckResult,
TrustlineCheckOptions,
// ─── Retry Policy ──────────────────────────────────────────────────────────
SubmissionOutcome,
RetryPolicy,
RetryPolicyExhaustedResult,
} from './types';

export { PocketPayError } from './types';
Expand Down Expand Up @@ -99,13 +103,17 @@ export { depositToVault, withdrawFromVault, getVaultBalance } from './soroban';
export {
submitTransactionIdempotently,
pollTransactionStatus,
withRetryPolicy,
} from './network';

// ─── Errors ─────────────────────────────────────────────────────────────────
export {
classifySubmitError,
isRetryableError,
isUnknownStatusError,
classifySubmissionOutcome,
isSafeToRetry,
requiresStatusCheck,
} from './errors';

// ─── Config ─────────────────────────────────────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions src/network/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,4 +238,5 @@ export async function executeSorobanOperation<T>(
}

export { submitTransactionIdempotently, pollTransactionStatus } from './idempotency';
export { withRetryPolicy } from './retry-policy';

Loading