Skip to content

Commit 6d6a21e

Browse files
rexx010PocketPay Contributor
andauthored
Implement SDK network resilience and endpoint diagnostics layer (#434)
* network: classify HTTP failures into typed NET_RATE_LIMITED/NET_UNREACHABLE errors NetworkClient previously collapsed every non-2xx response into a generic HTTP_ERROR_<status> code and every connection-level failure (ECONNREFUSED, ENOTFOUND, ...) into a generic NETWORK_ERROR. NET_RATE_LIMITED and NET_UNREACHABLE already existed in the published error registry (src/errors/codes.ts) but nothing ever produced them. - 429 responses now throw NET_RATE_LIMITED (retryable: true) - 5xx responses and socket/DNS failures (ECONNREFUSED, ENOTFOUND, EAI_AGAIN, ENETUNREACH, EHOSTUNREACH) now throw NET_UNREACHABLE (retryable: true) - Other 4xx statuses keep the existing HTTP_ERROR_<status> code for backward compatibility - Adds checkEndpointReachability(url): a lightweight GET probe that reports { reachable, latencyMs, errorCode } without ever exposing response bodies or headers — used by the new endpoint diagnostics probe Refs #272 (Files: src/network/index.ts, +116/-8 lines) * wallet: preserve FRIENDBOT_ERROR/FUND_ERROR mapping for new typed codes fundTestnetAccount() remapped NetworkClient failures by checking error.code.startsWith('HTTP_ERROR_'), which no longer matches 429/5xx responses now that those throw NET_RATE_LIMITED/NET_UNREACHABLE. Extends the check so Friendbot failures still surface as FRIENDBOT_ERROR, and fixes a pre-existing bug where retryable/category/safeMessage were silently dropped when the error was reconstructed (now passed through via the object-form PocketPayError constructor instead of positional args). External behaviour for existing consumers is unchanged: fund.test.ts (23 tests, all pre-existing) still passes without modification. Refs #272 (Files: src/wallet/index.ts, +7/-4 lines) * diagnostics: add opt-in live endpoint reachability probe Adds probeConfiguredEndpoints(), which checks the resolved Horizon and Soroban RPC URLs with checkEndpointReachability() and returns only URLs, latency, and typed error codes — never response bodies, headers, or secrets. Kept separate from buildDiagnosticsReport(), which stays a pure, side-effect-free config snapshot. This is opt-in: call it explicitly (e.g. from a support 'test connection' action) since it makes real network calls. Refs #272 (Files: src/diagnostics/probe.ts [new, 45 lines], src/diagnostics/index.ts, +3 lines) * index: export the network client abstraction from the package root NetworkClient, withTimeout, fetchWithTimeout, executeHorizonOperation, executeSorobanOperation, and checkEndpointReachability were implemented and used internally (e.g. by fundTestnetAccount) but never re-exported from src/index.ts, so consumers had no public entry point to the 'network client abstraction' required by issue #272. Also exports probeConfiguredEndpoints and the EndpointDiagnostics/EndpointReachability types. Refs #272 (Files: src/index.ts, +11 lines) * tests: cover timeout, rate-limit, unavailable endpoint, malformed config - network-client.test.ts (12 tests, new): NetworkClient success path; 429 -> NET_RATE_LIMITED; 503 -> NET_UNREACHABLE; other 4xx keeps HTTP_ERROR_<status>; ECONNREFUSED and ENOTFOUND -> NET_UNREACHABLE; unrecognized rejections -> NETWORK_ERROR; REQUEST_TIMEOUT on a slow response; single fetch call per request (no hidden retries); plus checkEndpointReachability success/failure/no-body-leak cases. - endpoint-diagnostics.test.ts (3 tests, new): probeConfiguredEndpoints reports both endpoints reachable; rejects malformed config (invalid network) before making any network call; never leaks secrets or response bodies into the report. - exports.test.ts: asserts NetworkClient and the rest of the network resilience layer are exported from the package root; adds probeConfiguredEndpoints to the diagnostics export list. All unit tests run offline (tests/setup/offline-guard.ts blocks real fetch calls). Full suite: 164/164 passing across the 9 touched/added test files; pre-existing unrelated failures elsewhere are untouched by this change (see PR description). Refs #272 (Files: tests/network-client.test.ts [new, 161 lines], tests/endpoint-diagnostics.test.ts [new, 58 lines], tests/exports.test.ts, +19 lines) * docs: add Network Resilience Layer guide, link NET_UNREACHABLE - docs/network-resilience.md (new): consolidated guide to NetworkClient, the typed failure codes (REQUEST_TIMEOUT, NET_RATE_LIMITED, NET_UNREACHABLE, HTTP_ERROR_<status>, NETWORK_ERROR), why read-only calls are safe to retry but transaction submission is not, and the two endpoint-diagnostics tools (config snapshot vs. live reachability probe). Cross-links network-errors.md and retry-policy.md rather than duplicating their content. - docs/network-errors.md: adds a NET_UNREACHABLE / NET_RATE_LIMITED row and links to the new guide. - README.md: adds the new guide to the docs index. Refs #272 (Files: docs/network-resilience.md [new, 125 lines], docs/network-errors.md, +6 lines, README.md, +1 line) --------- Co-authored-by: PocketPay Contributor <dev@example.com>
1 parent daeee42 commit 6d6a21e

11 files changed

Lines changed: 552 additions & 12 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ npm install @axionvera/pocketpay-sdk
6262
- [Local Mobile Consumption](./docs/local-mobile-consumption.md) - Safely test unpublished SDK changes in `pocketpay-mobile`with tarballs, links, local paths, or workspaces
6363
- [Transaction Date Formatting](./docs/transaction-timestamps.md) - Format of every `createdAt` timestamp returned by the SDK
6464
- [Network Error Handling](./docs/network-errors.md) - Retry guidance for Horizon, Friendbot, and Soroban RPC failures
65+
- [Network Resilience Layer](./docs/network-resilience.md) - The `NetworkClient` abstraction, typed timeout/rate-limit/unreachable errors, and endpoint reachability diagnostics
6566
- [Safe Retry Policy](./docs/retry-policy.md) - Classifying submission outcomes, safe retry rules, and the `withRetryPolicy` API
6667
- [Account Sequence & Concurrency Safety](./docs/sequence-safety.md) - Account sequence number handling, caching, stale sequence error classification, and in-process concurrency safety with SequenceProvider
6768
- [Meaningful Change Review Guide](./docs/meaningful-change-review.md) - what counts as real SDK work: behaviour, modules, tests, acceptance criteria + reviewer checks

docs/network-errors.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

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

5+
> See [Network Resilience Layer](./network-resilience.md) for the `NetworkClient`
6+
> abstraction, endpoint diagnostics, and how read-only retries differ from
7+
> transaction submission.
8+
59
## Overview
610

711
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.
@@ -20,6 +24,8 @@ These errors are temporary. Retry with exponential backoff (start at 1s, double
2024
| ECONNRESET / ETIMEDOUT | Network interruption | Retry with backoff |
2125
| REQUEST_TIMEOUT | SDK timeout during preparation or a plain read | Retry with backoff or increase `timeout` |
2226
| 500 Internal Server Error | Transient Horizon issue | Retry once, then fail |
27+
| NET_UNREACHABLE | Endpoint unreachable (5xx, ECONNREFUSED, DNS failure) — thrown by `NetworkClient`, `checkEndpointReachability` | Retry with backoff |
28+
| NET_RATE_LIMITED | 429 — thrown by `NetworkClient` with the typed code instead of a raw HTTP status | Retry after Retry-After |
2329

2430
### Friendbot
2531

docs/network-resilience.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Network Resilience Layer
2+
3+
This is the top-level guide to how the SDK talks to the network: the client
4+
abstraction, the typed errors it produces, how read-only calls differ from
5+
state-changing ones, and how to check endpoint health. For the full status
6+
code and result-code reference, see [Network Error Handling](./network-errors.md);
7+
for the transaction retry state machine, see [Safe Retry Policy](./retry-policy.md).
8+
9+
## `NetworkClient`
10+
11+
All raw `fetch` calls in the SDK are expected to go through `NetworkClient` (or
12+
`fetchWithTimeout`, which it's built on) rather than being made ad hoc from
13+
individual modules. It centralises three things every call needs: a timeout
14+
budget, JSON parsing, and typed error classification.
15+
16+
```ts
17+
import { NetworkClient } from 'stellar-pocketpay-sdk';
18+
19+
const client = new NetworkClient({
20+
baseUrl: 'https://friendbot.stellar.org',
21+
defaultTimeoutMs: 10_000,
22+
});
23+
24+
const data = await client.get('?addr=GABC...', { operation: 'Friendbot funding request' });
25+
```
26+
27+
`get()` and `post()` both accept a per-call `timeoutMs` and `operation` label
28+
(used in error messages and timeout-stage inference — see
29+
[Timeout Classification](./timeout-classification.md)).
30+
31+
## Typed failure codes
32+
33+
`NetworkClient` classifies every failure into one of these codes before
34+
throwing, so callers can branch on `error.code` / `error.retryable` instead of
35+
inspecting HTTP status numbers or `error.message`:
36+
37+
| Code | When | Retryable |
38+
|---|---|---|
39+
| `REQUEST_TIMEOUT` | The SDK's own timeout budget elapsed (see `withTimeout`) | Yes (unless the stage is `submission`/`confirmation` — see below) |
40+
| `NET_RATE_LIMITED` | HTTP 429 | Yes |
41+
| `NET_UNREACHABLE` | HTTP 5xx, or the request never reached the endpoint (`ECONNREFUSED`, `ENOTFOUND`, `EAI_AGAIN`, `ENETUNREACH`, `EHOSTUNREACH`) | Yes |
42+
| `HTTP_ERROR_<status>` | Any other non-2xx response (400, 401, 403, 404, …) | No |
43+
| `NETWORK_ERROR` | An unrecognized fetch rejection | Depends — inspect `error.cause` |
44+
45+
All of the above are published, stable codes in `ERROR_CODES` (see
46+
`src/errors/codes.ts`); use `describeError(code)` to get a safe user-facing
47+
message and developer hint for any of them.
48+
49+
```ts
50+
import { NetworkClient, describeError } from 'stellar-pocketpay-sdk';
51+
52+
try {
53+
await client.get('/accounts/GABC...');
54+
} catch (error) {
55+
if (error instanceof PocketPayError) {
56+
const { safeMessage, retryable } = describeError(error.code);
57+
if (retryable) {
58+
// back off and retry the same read-only request
59+
}
60+
}
61+
}
62+
```
63+
64+
## Read-only calls vs. state-changing submission
65+
66+
**Read-only requests** (account lookups, fee stats, transaction history,
67+
Friendbot funding) are safe to retry whenever `error.retryable` is `true`.
68+
Nothing on the server changes state just because a GET was retried.
69+
70+
**Transaction submission is different.** A submission that times out or hits
71+
`NET_UNREACHABLE` may or may not have reached the network — resubmitting
72+
blindly risks a duplicate payment. The SDK never does this automatically:
73+
74+
- `submitTransactionIdempotently()` resolves this by polling Horizon for the
75+
transaction hash before deciding anything.
76+
- `withRetryPolicy()` builds on that: it only resubmits the same envelope for
77+
`retryable_failure` outcomes, and always requires a status check
78+
(`requiresStatusCheck`) before any further action on `unknown_status`.
79+
80+
See [Safe Retry Policy](./retry-policy.md) for the full state machine. The
81+
short version: **retry reads freely; never retry a submission without going
82+
through `submitTransactionIdempotently` or `withRetryPolicy`.**
83+
84+
## Endpoint diagnostics
85+
86+
Two complementary tools are available, both safe to share with support —
87+
neither ever includes secret keys, signed XDR, or response bodies.
88+
89+
### Config snapshot (no network calls)
90+
91+
`buildDiagnosticsReport()` returns a redacted snapshot of the resolved
92+
configuration — network, Horizon/Soroban URLs, timeout, capability status —
93+
without making any request:
94+
95+
```ts
96+
import { buildDiagnosticsReport } from 'stellar-pocketpay-sdk';
97+
98+
const report = buildDiagnosticsReport({ network: 'testnet' });
99+
```
100+
101+
### Live reachability probe (opt-in network calls)
102+
103+
`checkEndpointReachability(url)` and `probeConfiguredEndpoints(config)` make a
104+
lightweight GET against an endpoint and report only whether it responded, how
105+
long it took, and a typed error code if it didn't — never the response body:
106+
107+
```ts
108+
import { probeConfiguredEndpoints } from 'stellar-pocketpay-sdk';
109+
110+
const diagnostics = await probeConfiguredEndpoints({ network: 'testnet' });
111+
// { generatedAt, horizon: { url, reachable, latencyMs }, sorobanRpc: { ... } }
112+
```
113+
114+
Because this makes real network calls, it is never invoked automatically by
115+
`buildDiagnosticsReport()` — call it explicitly when you need to check live
116+
connectivity (e.g. a support "test connection" button).
117+
118+
## Malformed configuration
119+
120+
Every network call resolves configuration through `resolveConfig()` /
121+
`validatePocketPayConfig()` first, which validates the network name, Horizon
122+
and Soroban RPC URLs, timeout, and contract ID before any request is made. An
123+
invalid config throws (or, for `validatePocketPayConfig`, reports structured
124+
issues) instead of silently falling back to a default — see
125+
[SDK Configuration](./configuration.md) for the full validation rules.

src/diagnostics/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,6 @@ export {
3838
} from './hooks';
3939

4040
export { buildDiagnosticsReport } from './report';
41+
42+
export { probeConfiguredEndpoints } from './probe';
43+
export type { EndpointDiagnostics } from './probe';

src/diagnostics/probe.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Live endpoint reachability probing for diagnostics.
3+
*
4+
* Unlike {@link buildDiagnosticsReport}, which is a pure config snapshot,
5+
* this module makes real network calls. It is opt-in and never included in
6+
* `buildDiagnosticsReport` automatically, so existing callers keep a
7+
* side-effect-free report by default.
8+
*/
9+
10+
import { resolveConfig } from '../config';
11+
import { checkEndpointReachability, type EndpointReachability } from '../network';
12+
import type { SDKConfig } from '../types';
13+
14+
/** Reachability of the SDK's configured Horizon and Soroban RPC endpoints. */
15+
export interface EndpointDiagnostics {
16+
generatedAt: string;
17+
horizon: EndpointReachability;
18+
sorobanRpc: EndpointReachability;
19+
}
20+
21+
/**
22+
* Probes the configured Horizon and Soroban RPC endpoints and reports
23+
* whether each responded. Contains only URLs, timing, and typed error
24+
* codes — no secrets, headers, or response bodies.
25+
*
26+
* @param overrides - Optional SDK config overrides
27+
* @param timeoutMs - Per-endpoint probe timeout (default: 5000)
28+
*/
29+
export async function probeConfiguredEndpoints(
30+
overrides?: Partial<SDKConfig>,
31+
timeoutMs = 5_000,
32+
): Promise<EndpointDiagnostics> {
33+
const config = resolveConfig(overrides);
34+
35+
const [horizon, sorobanRpc] = await Promise.all([
36+
checkEndpointReachability(config.horizonUrl, timeoutMs),
37+
checkEndpointReachability(config.sorobanRpcUrl, timeoutMs),
38+
]);
39+
40+
return {
41+
generatedAt: new Date().toISOString(),
42+
horizon,
43+
sorobanRpc,
44+
};
45+
}

src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,17 @@ export {
329329
pollTransactionStatus,
330330
withRetryPolicy,
331331
fetchFeeEstimate,
332+
// Network resilience layer (issue #272)
333+
NetworkClient,
334+
withTimeout,
335+
fetchWithTimeout,
336+
executeHorizonOperation,
337+
executeSorobanOperation,
338+
checkEndpointReachability,
332339
} from './network';
333340

341+
export type { EndpointReachability } from './network';
342+
334343
// ─── Errors ─────────────────────────────────────────────────────────────────
335344
export {
336345
classifySubmitError,
@@ -376,6 +385,7 @@ export type {
376385
DiagnosticsReport,
377386
DiagnosticsEvent,
378387
DiagnosticsSensitiveKey,
388+
EndpointDiagnostics,
379389
} from './diagnostics';
380390

381391
export {
@@ -392,6 +402,7 @@ export {
392402
getDiagnosticsHooks,
393403
emitDiagnosticsEvent,
394404
buildDiagnosticsReport,
405+
probeConfiguredEndpoints,
395406
} from './diagnostics';
396407

397408
export type {

src/network/index.ts

Lines changed: 116 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,81 @@ import { ErrorCode, ERROR_CODES } from '../errors/codes';
1212

1313
const FALLBACK_TIMEOUT_MS = 30_000;
1414

15+
/**
16+
* Low-level socket/DNS error codes that mean the endpoint itself could not
17+
* be reached — distinct from a request that reached the server and merely
18+
* timed out or was rate-limited. Node, browsers, and undici all surface
19+
* these on `error.code` or a wrapped `error.cause.code`.
20+
*/
21+
const UNREACHABLE_ERROR_CODES = new Set([
22+
'ECONNREFUSED',
23+
'ENOTFOUND',
24+
'EAI_AGAIN',
25+
'ENETUNREACH',
26+
'EHOSTUNREACH',
27+
]);
28+
29+
/** Extracts a low-level network error code from a raw fetch rejection. */
30+
function nativeErrorCode(error: unknown): string | undefined {
31+
const err = error as { code?: unknown; cause?: { code?: unknown } };
32+
return (
33+
(typeof err?.code === 'string' && err.code) ||
34+
(typeof err?.cause?.code === 'string' && err.cause.code) ||
35+
undefined
36+
);
37+
}
38+
39+
/**
40+
* Builds a typed `NET_UNREACHABLE` error for a socket/DNS-level failure,
41+
* i.e. the request never reached the endpoint at all.
42+
*/
43+
function unreachableError(operation: string, cause: Error): PocketPayError {
44+
const spec = ERROR_CODES[ErrorCode.NET_UNREACHABLE];
45+
return new PocketPayError(
46+
`${operation} could not reach the endpoint: ${cause.message}`,
47+
ErrorCode.NET_UNREACHABLE,
48+
{ category: spec.category, safeMessage: spec.safeMessage, cause },
49+
undefined,
50+
true,
51+
);
52+
}
53+
54+
/**
55+
* Builds a typed error for a non-2xx HTTP response. 429 maps to
56+
* `NET_RATE_LIMITED` and 5xx maps to `NET_UNREACHABLE` (the endpoint is up
57+
* but not currently serving requests); both are retryable. Other statuses
58+
* keep the existing `HTTP_ERROR_<status>` code for backward compatibility.
59+
*/
60+
function httpStatusError(operation: string, status: number, detail: string): PocketPayError {
61+
const msg = detail
62+
? `${operation} failed with status ${status}: ${detail}`
63+
: `${operation} failed with status ${status}`;
64+
65+
if (status === 429) {
66+
const spec = ERROR_CODES[ErrorCode.NET_RATE_LIMITED];
67+
return new PocketPayError(
68+
msg,
69+
ErrorCode.NET_RATE_LIMITED,
70+
{ statusCode: status, category: spec.category, safeMessage: spec.safeMessage },
71+
undefined,
72+
true,
73+
);
74+
}
75+
76+
if (status >= 500) {
77+
const spec = ERROR_CODES[ErrorCode.NET_UNREACHABLE];
78+
return new PocketPayError(
79+
msg,
80+
ErrorCode.NET_UNREACHABLE,
81+
{ statusCode: status, category: spec.category, safeMessage: spec.safeMessage },
82+
undefined,
83+
true,
84+
);
85+
}
86+
87+
return new PocketPayError(msg, `HTTP_ERROR_${status}`, status);
88+
}
89+
1590
/**
1691
* Infers the lifecycle stage from the operation label a caller already passes.
1792
*
@@ -233,21 +308,19 @@ export class NetworkClient {
233308
typeof errorBody === 'string'
234309
? errorBody
235310
: (errorBody as any)?.detail || (errorBody as any)?.message || '';
236-
const msg = bodyStr
237-
? `${operation} failed with status ${response.status}: ${bodyStr}`
238-
: `${operation} failed with status ${response.status}`;
239-
throw new PocketPayError(
240-
msg,
241-
`HTTP_ERROR_${response.status}`,
242-
response.status,
243-
);
311+
throw httpStatusError(operation, response.status, bodyStr);
244312
}
245313

246314
return (await response.json()) as T;
247315
} catch (error) {
248316
if (error instanceof PocketPayError) {
249317
throw error;
250318
}
319+
// A socket/DNS-level code means the endpoint itself is unreachable,
320+
// as opposed to a request that reached the server and failed there.
321+
if (error instanceof Error && UNREACHABLE_ERROR_CODES.has(nativeErrorCode(error) ?? '')) {
322+
throw unreachableError(operation, error);
323+
}
251324
throw wrapError(error, operation, 'NETWORK_ERROR');
252325
}
253326
}
@@ -297,6 +370,41 @@ export async function executeSorobanOperation<T>(
297370
}
298371
}
299372

373+
/** Result of an endpoint reachability probe. Contains no request/response bodies. */
374+
export interface EndpointReachability {
375+
url: string;
376+
reachable: boolean;
377+
latencyMs?: number;
378+
/** Typed error code when unreachable (e.g. NET_UNREACHABLE, REQUEST_TIMEOUT). */
379+
errorCode?: string;
380+
}
381+
382+
/**
383+
* Probes an endpoint with a lightweight GET and reports whether it responded,
384+
* without exposing response bodies, headers, or request payloads. Any HTTP
385+
* status (including 4xx/5xx) counts as "reachable" — this checks connectivity,
386+
* not application-level success.
387+
*
388+
* @param url - Endpoint to probe (e.g. a resolved Horizon or Soroban RPC URL)
389+
* @param timeoutMs - Probe timeout in milliseconds (default: 5000)
390+
*/
391+
export async function checkEndpointReachability(
392+
url: string,
393+
timeoutMs = 5_000,
394+
): Promise<EndpointReachability> {
395+
const startedAt = Date.now();
396+
try {
397+
await fetchWithTimeout(url, { method: 'GET' }, 'Endpoint reachability probe', timeoutMs);
398+
return { url, reachable: true, latencyMs: Date.now() - startedAt };
399+
} catch (error) {
400+
// A PocketPayError (e.g. REQUEST_TIMEOUT) already carries a typed code.
401+
// Any other rejection (ECONNREFUSED, ENOTFOUND, etc.) means the socket
402+
// or DNS lookup itself failed, which we surface uniformly as unreachable.
403+
const code = error instanceof PocketPayError ? error.code : ErrorCode.NET_UNREACHABLE;
404+
return { url, reachable: false, latencyMs: Date.now() - startedAt, errorCode: code };
405+
}
406+
}
407+
300408
export { submitTransactionIdempotently, pollTransactionStatus } from './idempotency';
301409
export { withRetryPolicy } from './retry-policy';
302410
export { fetchFeeEstimate } from './fee';

0 commit comments

Comments
 (0)