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 @@ -62,6 +62,7 @@ npm install @axionvera/pocketpay-sdk
- [Local Mobile Consumption](./docs/local-mobile-consumption.md) - Safely test unpublished SDK changes in `pocketpay-mobile`with tarballs, links, local paths, or workspaces
- [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
- [Network Resilience Layer](./docs/network-resilience.md) - The `NetworkClient` abstraction, typed timeout/rate-limit/unreachable errors, and endpoint reachability diagnostics
- [Safe Retry Policy](./docs/retry-policy.md) - Classifying submission outcomes, safe retry rules, and the `withRetryPolicy` API
- [Account Sequence & Concurrency Safety](./docs/sequence-safety.md) - Account sequence number handling, caching, stale sequence error classification, and in-process concurrency safety with SequenceProvider
- [Meaningful Change Review Guide](./docs/meaningful-change-review.md) - what counts as real SDK work: behaviour, modules, tests, acceptance criteria + reviewer checks
Expand Down
6 changes: 6 additions & 0 deletions docs/network-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

This guide helps PocketPay SDK consumers handle transient failures from Stellar network services correctly.

> See [Network Resilience Layer](./network-resilience.md) for the `NetworkClient`
> abstraction, endpoint diagnostics, and how read-only retries differ from
> transaction submission.

## Overview

Stellar network calls can fail for different reasons. Some failures are temporary and should be retried. Others indicate a problem with the request or account state and should be shown to the user.
Expand All @@ -20,6 +24,8 @@ These errors are temporary. Retry with exponential backoff (start at 1s, double
| ECONNRESET / ETIMEDOUT | Network interruption | Retry with backoff |
| REQUEST_TIMEOUT | SDK timeout during preparation or a plain read | Retry with backoff or increase `timeout` |
| 500 Internal Server Error | Transient Horizon issue | Retry once, then fail |
| NET_UNREACHABLE | Endpoint unreachable (5xx, ECONNREFUSED, DNS failure) — thrown by `NetworkClient`, `checkEndpointReachability` | Retry with backoff |
| NET_RATE_LIMITED | 429 — thrown by `NetworkClient` with the typed code instead of a raw HTTP status | Retry after Retry-After |

### Friendbot

Expand Down
125 changes: 125 additions & 0 deletions docs/network-resilience.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Network Resilience Layer

This is the top-level guide to how the SDK talks to the network: the client
abstraction, the typed errors it produces, how read-only calls differ from
state-changing ones, and how to check endpoint health. For the full status
code and result-code reference, see [Network Error Handling](./network-errors.md);
for the transaction retry state machine, see [Safe Retry Policy](./retry-policy.md).

## `NetworkClient`

All raw `fetch` calls in the SDK are expected to go through `NetworkClient` (or
`fetchWithTimeout`, which it's built on) rather than being made ad hoc from
individual modules. It centralises three things every call needs: a timeout
budget, JSON parsing, and typed error classification.

```ts
import { NetworkClient } from 'stellar-pocketpay-sdk';

const client = new NetworkClient({
baseUrl: 'https://friendbot.stellar.org',
defaultTimeoutMs: 10_000,
});

const data = await client.get('?addr=GABC...', { operation: 'Friendbot funding request' });
```

`get()` and `post()` both accept a per-call `timeoutMs` and `operation` label
(used in error messages and timeout-stage inference — see
[Timeout Classification](./timeout-classification.md)).

## Typed failure codes

`NetworkClient` classifies every failure into one of these codes before
throwing, so callers can branch on `error.code` / `error.retryable` instead of
inspecting HTTP status numbers or `error.message`:

| Code | When | Retryable |
|---|---|---|
| `REQUEST_TIMEOUT` | The SDK's own timeout budget elapsed (see `withTimeout`) | Yes (unless the stage is `submission`/`confirmation` — see below) |
| `NET_RATE_LIMITED` | HTTP 429 | Yes |
| `NET_UNREACHABLE` | HTTP 5xx, or the request never reached the endpoint (`ECONNREFUSED`, `ENOTFOUND`, `EAI_AGAIN`, `ENETUNREACH`, `EHOSTUNREACH`) | Yes |
| `HTTP_ERROR_<status>` | Any other non-2xx response (400, 401, 403, 404, …) | No |
| `NETWORK_ERROR` | An unrecognized fetch rejection | Depends — inspect `error.cause` |

All of the above are published, stable codes in `ERROR_CODES` (see
`src/errors/codes.ts`); use `describeError(code)` to get a safe user-facing
message and developer hint for any of them.

```ts
import { NetworkClient, describeError } from 'stellar-pocketpay-sdk';

try {
await client.get('/accounts/GABC...');
} catch (error) {
if (error instanceof PocketPayError) {
const { safeMessage, retryable } = describeError(error.code);
if (retryable) {
// back off and retry the same read-only request
}
}
}
```

## Read-only calls vs. state-changing submission

**Read-only requests** (account lookups, fee stats, transaction history,
Friendbot funding) are safe to retry whenever `error.retryable` is `true`.
Nothing on the server changes state just because a GET was retried.

**Transaction submission is different.** A submission that times out or hits
`NET_UNREACHABLE` may or may not have reached the network — resubmitting
blindly risks a duplicate payment. The SDK never does this automatically:

- `submitTransactionIdempotently()` resolves this by polling Horizon for the
transaction hash before deciding anything.
- `withRetryPolicy()` builds on that: it only resubmits the same envelope for
`retryable_failure` outcomes, and always requires a status check
(`requiresStatusCheck`) before any further action on `unknown_status`.

See [Safe Retry Policy](./retry-policy.md) for the full state machine. The
short version: **retry reads freely; never retry a submission without going
through `submitTransactionIdempotently` or `withRetryPolicy`.**

## Endpoint diagnostics

Two complementary tools are available, both safe to share with support —
neither ever includes secret keys, signed XDR, or response bodies.

### Config snapshot (no network calls)

`buildDiagnosticsReport()` returns a redacted snapshot of the resolved
configuration — network, Horizon/Soroban URLs, timeout, capability status —
without making any request:

```ts
import { buildDiagnosticsReport } from 'stellar-pocketpay-sdk';

const report = buildDiagnosticsReport({ network: 'testnet' });
```

### Live reachability probe (opt-in network calls)

`checkEndpointReachability(url)` and `probeConfiguredEndpoints(config)` make a
lightweight GET against an endpoint and report only whether it responded, how
long it took, and a typed error code if it didn't — never the response body:

```ts
import { probeConfiguredEndpoints } from 'stellar-pocketpay-sdk';

const diagnostics = await probeConfiguredEndpoints({ network: 'testnet' });
// { generatedAt, horizon: { url, reachable, latencyMs }, sorobanRpc: { ... } }
```

Because this makes real network calls, it is never invoked automatically by
`buildDiagnosticsReport()` — call it explicitly when you need to check live
connectivity (e.g. a support "test connection" button).

## Malformed configuration

Every network call resolves configuration through `resolveConfig()` /
`validatePocketPayConfig()` first, which validates the network name, Horizon
and Soroban RPC URLs, timeout, and contract ID before any request is made. An
invalid config throws (or, for `validatePocketPayConfig`, reports structured
issues) instead of silently falling back to a default — see
[SDK Configuration](./configuration.md) for the full validation rules.
3 changes: 3 additions & 0 deletions src/diagnostics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@ export {
} from './hooks';

export { buildDiagnosticsReport } from './report';

export { probeConfiguredEndpoints } from './probe';
export type { EndpointDiagnostics } from './probe';
45 changes: 45 additions & 0 deletions src/diagnostics/probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Live endpoint reachability probing for diagnostics.
*
* Unlike {@link buildDiagnosticsReport}, which is a pure config snapshot,
* this module makes real network calls. It is opt-in and never included in
* `buildDiagnosticsReport` automatically, so existing callers keep a
* side-effect-free report by default.
*/

import { resolveConfig } from '../config';
import { checkEndpointReachability, type EndpointReachability } from '../network';
import type { SDKConfig } from '../types';

/** Reachability of the SDK's configured Horizon and Soroban RPC endpoints. */
export interface EndpointDiagnostics {
generatedAt: string;
horizon: EndpointReachability;
sorobanRpc: EndpointReachability;
}

/**
* Probes the configured Horizon and Soroban RPC endpoints and reports
* whether each responded. Contains only URLs, timing, and typed error
* codes — no secrets, headers, or response bodies.
*
* @param overrides - Optional SDK config overrides
* @param timeoutMs - Per-endpoint probe timeout (default: 5000)
*/
export async function probeConfiguredEndpoints(
overrides?: Partial<SDKConfig>,
timeoutMs = 5_000,
): Promise<EndpointDiagnostics> {
const config = resolveConfig(overrides);

const [horizon, sorobanRpc] = await Promise.all([
checkEndpointReachability(config.horizonUrl, timeoutMs),
checkEndpointReachability(config.sorobanRpcUrl, timeoutMs),
]);

return {
generatedAt: new Date().toISOString(),
horizon,
sorobanRpc,
};
}
11 changes: 11 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,8 +329,17 @@ export {
pollTransactionStatus,
withRetryPolicy,
fetchFeeEstimate,
// Network resilience layer (issue #272)
NetworkClient,
withTimeout,
fetchWithTimeout,
executeHorizonOperation,
executeSorobanOperation,
checkEndpointReachability,
} from './network';

export type { EndpointReachability } from './network';

// ─── Errors ─────────────────────────────────────────────────────────────────
export {
classifySubmitError,
Expand Down Expand Up @@ -376,6 +385,7 @@ export type {
DiagnosticsReport,
DiagnosticsEvent,
DiagnosticsSensitiveKey,
EndpointDiagnostics,
} from './diagnostics';

export {
Expand All @@ -392,6 +402,7 @@ export {
getDiagnosticsHooks,
emitDiagnosticsEvent,
buildDiagnosticsReport,
probeConfiguredEndpoints,
} from './diagnostics';

export type {
Expand Down
124 changes: 116 additions & 8 deletions src/network/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,81 @@ import { ErrorCode, ERROR_CODES } from '../errors/codes';

const FALLBACK_TIMEOUT_MS = 30_000;

/**
* Low-level socket/DNS error codes that mean the endpoint itself could not
* be reached — distinct from a request that reached the server and merely
* timed out or was rate-limited. Node, browsers, and undici all surface
* these on `error.code` or a wrapped `error.cause.code`.
*/
const UNREACHABLE_ERROR_CODES = new Set([
'ECONNREFUSED',
'ENOTFOUND',
'EAI_AGAIN',
'ENETUNREACH',
'EHOSTUNREACH',
]);

/** Extracts a low-level network error code from a raw fetch rejection. */
function nativeErrorCode(error: unknown): string | undefined {
const err = error as { code?: unknown; cause?: { code?: unknown } };
return (
(typeof err?.code === 'string' && err.code) ||
(typeof err?.cause?.code === 'string' && err.cause.code) ||
undefined
);
}

/**
* Builds a typed `NET_UNREACHABLE` error for a socket/DNS-level failure,
* i.e. the request never reached the endpoint at all.
*/
function unreachableError(operation: string, cause: Error): PocketPayError {
const spec = ERROR_CODES[ErrorCode.NET_UNREACHABLE];
return new PocketPayError(
`${operation} could not reach the endpoint: ${cause.message}`,
ErrorCode.NET_UNREACHABLE,
{ category: spec.category, safeMessage: spec.safeMessage, cause },
undefined,
true,
);
}

/**
* Builds a typed error for a non-2xx HTTP response. 429 maps to
* `NET_RATE_LIMITED` and 5xx maps to `NET_UNREACHABLE` (the endpoint is up
* but not currently serving requests); both are retryable. Other statuses
* keep the existing `HTTP_ERROR_<status>` code for backward compatibility.
*/
function httpStatusError(operation: string, status: number, detail: string): PocketPayError {
const msg = detail
? `${operation} failed with status ${status}: ${detail}`
: `${operation} failed with status ${status}`;

if (status === 429) {
const spec = ERROR_CODES[ErrorCode.NET_RATE_LIMITED];
return new PocketPayError(
msg,
ErrorCode.NET_RATE_LIMITED,
{ statusCode: status, category: spec.category, safeMessage: spec.safeMessage },
undefined,
true,
);
}

if (status >= 500) {
const spec = ERROR_CODES[ErrorCode.NET_UNREACHABLE];
return new PocketPayError(
msg,
ErrorCode.NET_UNREACHABLE,
{ statusCode: status, category: spec.category, safeMessage: spec.safeMessage },
undefined,
true,
);
}

return new PocketPayError(msg, `HTTP_ERROR_${status}`, status);
}

/**
* Infers the lifecycle stage from the operation label a caller already passes.
*
Expand Down Expand Up @@ -233,21 +308,19 @@ export class NetworkClient {
typeof errorBody === 'string'
? errorBody
: (errorBody as any)?.detail || (errorBody as any)?.message || '';
const msg = bodyStr
? `${operation} failed with status ${response.status}: ${bodyStr}`
: `${operation} failed with status ${response.status}`;
throw new PocketPayError(
msg,
`HTTP_ERROR_${response.status}`,
response.status,
);
throw httpStatusError(operation, response.status, bodyStr);
}

return (await response.json()) as T;
} catch (error) {
if (error instanceof PocketPayError) {
throw error;
}
// A socket/DNS-level code means the endpoint itself is unreachable,
// as opposed to a request that reached the server and failed there.
if (error instanceof Error && UNREACHABLE_ERROR_CODES.has(nativeErrorCode(error) ?? '')) {
throw unreachableError(operation, error);
}
throw wrapError(error, operation, 'NETWORK_ERROR');
}
}
Expand Down Expand Up @@ -297,6 +370,41 @@ export async function executeSorobanOperation<T>(
}
}

/** Result of an endpoint reachability probe. Contains no request/response bodies. */
export interface EndpointReachability {
url: string;
reachable: boolean;
latencyMs?: number;
/** Typed error code when unreachable (e.g. NET_UNREACHABLE, REQUEST_TIMEOUT). */
errorCode?: string;
}

/**
* Probes an endpoint with a lightweight GET and reports whether it responded,
* without exposing response bodies, headers, or request payloads. Any HTTP
* status (including 4xx/5xx) counts as "reachable" — this checks connectivity,
* not application-level success.
*
* @param url - Endpoint to probe (e.g. a resolved Horizon or Soroban RPC URL)
* @param timeoutMs - Probe timeout in milliseconds (default: 5000)
*/
export async function checkEndpointReachability(
url: string,
timeoutMs = 5_000,
): Promise<EndpointReachability> {
const startedAt = Date.now();
try {
await fetchWithTimeout(url, { method: 'GET' }, 'Endpoint reachability probe', timeoutMs);
return { url, reachable: true, latencyMs: Date.now() - startedAt };
} catch (error) {
// A PocketPayError (e.g. REQUEST_TIMEOUT) already carries a typed code.
// Any other rejection (ECONNREFUSED, ENOTFOUND, etc.) means the socket
// or DNS lookup itself failed, which we surface uniformly as unreachable.
const code = error instanceof PocketPayError ? error.code : ErrorCode.NET_UNREACHABLE;
return { url, reachable: false, latencyMs: Date.now() - startedAt, errorCode: code };
}
}

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