diff --git a/README.md b/README.md index 4d99917..893966c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/network-errors.md b/docs/network-errors.md index ebb1d89..9653c6f 100644 --- a/docs/network-errors.md +++ b/docs/network-errors.md @@ -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. @@ -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 diff --git a/docs/network-resilience.md b/docs/network-resilience.md new file mode 100644 index 0000000..c07fef4 --- /dev/null +++ b/docs/network-resilience.md @@ -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_` | 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. diff --git a/src/diagnostics/index.ts b/src/diagnostics/index.ts index 996d42d..ee8dd3b 100644 --- a/src/diagnostics/index.ts +++ b/src/diagnostics/index.ts @@ -38,3 +38,6 @@ export { } from './hooks'; export { buildDiagnosticsReport } from './report'; + +export { probeConfiguredEndpoints } from './probe'; +export type { EndpointDiagnostics } from './probe'; diff --git a/src/diagnostics/probe.ts b/src/diagnostics/probe.ts new file mode 100644 index 0000000..681a858 --- /dev/null +++ b/src/diagnostics/probe.ts @@ -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, + timeoutMs = 5_000, +): Promise { + 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, + }; +} diff --git a/src/index.ts b/src/index.ts index d2db501..0904716 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, @@ -376,6 +385,7 @@ export type { DiagnosticsReport, DiagnosticsEvent, DiagnosticsSensitiveKey, + EndpointDiagnostics, } from './diagnostics'; export { @@ -392,6 +402,7 @@ export { getDiagnosticsHooks, emitDiagnosticsEvent, buildDiagnosticsReport, + probeConfiguredEndpoints, } from './diagnostics'; export type { diff --git a/src/network/index.ts b/src/network/index.ts index 676e060..3fac9f5 100644 --- a/src/network/index.ts +++ b/src/network/index.ts @@ -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_` 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. * @@ -233,14 +308,7 @@ 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; @@ -248,6 +316,11 @@ export class NetworkClient { 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'); } } @@ -297,6 +370,41 @@ export async function executeSorobanOperation( } } +/** 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 { + 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'; diff --git a/src/wallet/index.ts b/src/wallet/index.ts index fd3bef0..76cb7c8 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -351,13 +351,16 @@ export async function fundTestnetAccount( }; } catch (error) { if (error instanceof PocketPayError) { - // Map general HTTP errors to Friendbot-specific error code - if (error.code.startsWith('HTTP_ERROR_')) { + // Map HTTP-level failures (including the typed NET_RATE_LIMITED and + // NET_UNREACHABLE codes) to the Friendbot-specific error code, keeping + // retryability so callers can still branch on error.retryable. + if (error.code.startsWith('HTTP_ERROR_') || error.code === ErrorCode.NET_RATE_LIMITED || error.code === ErrorCode.NET_UNREACHABLE) { throw new PocketPayError( error.message, 'FRIENDBOT_ERROR', - error.statusCode, - error.cause, + { statusCode: error.statusCode, cause: error.cause }, + undefined, + error.retryable, ); } if (error.code === 'NETWORK_ERROR') { diff --git a/tests/endpoint-diagnostics.test.ts b/tests/endpoint-diagnostics.test.ts new file mode 100644 index 0000000..eeabc92 --- /dev/null +++ b/tests/endpoint-diagnostics.test.ts @@ -0,0 +1,58 @@ +/** + * Tests for probeConfiguredEndpoints (issue #272) — live reachability + * diagnostics for the configured Horizon and Soroban RPC endpoints. + * All fetch calls are mocked so the suite runs offline. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { probeConfiguredEndpoints } from '../src/diagnostics'; +import { PocketPayError } from '../src/types'; + +function mockFetchOk(): void { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}), + text: async () => '{}', + } as Response), + ); +} + +describe('probeConfiguredEndpoints', () => { + it('reports both configured endpoints as reachable', async () => { + mockFetchOk(); + const report = await probeConfiguredEndpoints({ network: 'testnet' }); + + expect(report.horizon.reachable).toBe(true); + expect(report.sorobanRpc.reachable).toBe(true); + expect(report.horizon.url).toContain('horizon-testnet.stellar.org'); + expect(report.sorobanRpc.url).toContain('soroban-testnet.stellar.org'); + }); + + it('rejects malformed config before making any network calls', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + await expect( + probeConfiguredEndpoints({ network: 'not-a-real-network' as any }), + ).rejects.toBeInstanceOf(PocketPayError); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('never includes secrets or response bodies in the report', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ secretKey: 'SSHOULDNEVERAPPEAR' }), + text: async () => '{"secretKey":"SSHOULDNEVERAPPEAR"}', + } as Response), + ); + + const report = await probeConfiguredEndpoints({ network: 'testnet' }); + expect(JSON.stringify(report)).not.toContain('SSHOULDNEVERAPPEAR'); + }); +}); diff --git a/tests/exports.test.ts b/tests/exports.test.ts index 197a674..75b0c48 100644 --- a/tests/exports.test.ts +++ b/tests/exports.test.ts @@ -102,6 +102,19 @@ diagnostics: [ 'emitDiagnosticsEvent', 'buildDiagnosticsReport', 'redactDiagnosticsValue', +'probeConfiguredEndpoints', +], +network: [ +'NetworkClient', +'withTimeout', +'fetchWithTimeout', +'executeHorizonOperation', +'executeSorobanOperation', +'checkEndpointReachability', +'submitTransactionIdempotently', +'pollTransactionStatus', +'withRetryPolicy', +'fetchFeeEstimate', ], } as const; @@ -158,6 +171,12 @@ describe('Package root exports', () => { } }); + it('exports the network resilience layer from the package root', () => { + for (const name of REQUIRED_PUBLIC_EXPORTS.network) { + expectExported(name); + } + }); + it('exports errors and public types from the package root', () => { for (const name of REQUIRED_PUBLIC_EXPORTS.errors) { expectExported(name); diff --git a/tests/network-client.test.ts b/tests/network-client.test.ts new file mode 100644 index 0000000..17780dd --- /dev/null +++ b/tests/network-client.test.ts @@ -0,0 +1,161 @@ +/** + * Tests for the NetworkClient abstraction and its typed error classification + * (issue #272). All fetch calls are mocked so the suite runs offline. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { NetworkClient, checkEndpointReachability } from '../src/network'; +import { PocketPayError } from '../src/types'; +import { ErrorCode } from '../src/errors'; + +function mockFetchOk(body: unknown, status = 200): void { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => JSON.stringify(body), + } as Response), + ); +} + +function mockFetchStatus(status: number, body: unknown = { message: 'failed' }): void { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status, + json: async () => body, + text: async () => JSON.stringify(body), + } as Response), + ); +} + +function mockFetchRejection(error: Error): void { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(error)); +} + +function connectionRefused(): Error { + const error = new Error('connect ECONNREFUSED 127.0.0.1:443') as Error & { code: string }; + error.code = 'ECONNREFUSED'; + return error; +} + +describe('NetworkClient', () => { + it('returns parsed JSON on a successful GET', async () => { + mockFetchOk({ hello: 'world' }); + const client = new NetworkClient({ baseUrl: 'https://example.test' }); + await expect(client.get('/ping')).resolves.toEqual({ hello: 'world' }); + }); + + it('classifies HTTP 429 as the typed NET_RATE_LIMITED code', async () => { + mockFetchStatus(429, { message: 'slow down' }); + const client = new NetworkClient({ baseUrl: 'https://example.test' }); + + await expect(client.get('/ping')).rejects.toMatchObject({ + code: ErrorCode.NET_RATE_LIMITED, + statusCode: 429, + retryable: true, + }); + }); + + it('classifies HTTP 503 as the typed NET_UNREACHABLE code', async () => { + mockFetchStatus(503, 'Service Unavailable'); + const client = new NetworkClient({ baseUrl: 'https://example.test' }); + + await expect(client.get('/ping')).rejects.toMatchObject({ + code: ErrorCode.NET_UNREACHABLE, + statusCode: 503, + retryable: true, + }); + }); + + it('keeps the legacy HTTP_ERROR_ code for other 4xx statuses', async () => { + mockFetchStatus(404, 'Not Found'); + const client = new NetworkClient({ baseUrl: 'https://example.test' }); + + await expect(client.get('/ping')).rejects.toMatchObject({ + code: 'HTTP_ERROR_404', + statusCode: 404, + }); + }); + + it('classifies a connection-refused rejection as NET_UNREACHABLE', async () => { + mockFetchRejection(connectionRefused()); + const client = new NetworkClient({ baseUrl: 'https://example.test' }); + + await expect(client.get('/ping')).rejects.toMatchObject({ + code: ErrorCode.NET_UNREACHABLE, + retryable: true, + }); + }); + + it('classifies a DNS lookup failure (ENOTFOUND) as NET_UNREACHABLE', async () => { + const error = new Error('getaddrinfo ENOTFOUND example.invalid') as Error & { code: string }; + error.code = 'ENOTFOUND'; + mockFetchRejection(error); + const client = new NetworkClient({ baseUrl: 'https://example.test' }); + + await expect(client.get('/ping')).rejects.toMatchObject({ + code: ErrorCode.NET_UNREACHABLE, + }); + }); + + it('falls back to NETWORK_ERROR for unrecognized fetch rejections', async () => { + mockFetchRejection(new Error('something odd happened')); + const client = new NetworkClient({ baseUrl: 'https://example.test' }); + + await expect(client.get('/ping')).rejects.toMatchObject({ + code: 'NETWORK_ERROR', + }); + }); + + it('surfaces a REQUEST_TIMEOUT when the request exceeds its budget', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((_url: string, init?: RequestInit) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('The operation was aborted.', 'AbortError')); + }); + })), + ); + const client = new NetworkClient({ baseUrl: 'https://example.test', defaultTimeoutMs: 5 }); + + await expect(client.get('/slow')).rejects.toMatchObject({ + code: 'REQUEST_TIMEOUT', + }); + }); + + it('does not retry on its own — callers own retry behaviour', async () => { + mockFetchStatus(500, 'boom'); + const client = new NetworkClient({ baseUrl: 'https://example.test' }); + const err = await client.get('/ping').catch((e) => e as PocketPayError); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(err).toBeInstanceOf(PocketPayError); + }); +}); + +describe('checkEndpointReachability', () => { + it('reports reachable:true for any HTTP response, including error statuses', async () => { + mockFetchStatus(500, 'boom'); + const result = await checkEndpointReachability('https://example.test'); + expect(result.reachable).toBe(true); + expect(result.url).toBe('https://example.test'); + expect(typeof result.latencyMs).toBe('number'); + }); + + it('reports reachable:false with a NET_UNREACHABLE code on connection failure', async () => { + mockFetchRejection(connectionRefused()); + const result = await checkEndpointReachability('https://example.test'); + expect(result.reachable).toBe(false); + expect(result.errorCode).toBe(ErrorCode.NET_UNREACHABLE); + }); + + it('never includes response bodies or headers in the result', async () => { + mockFetchOk({ secretKey: 'SSHOULDNEVERAPPEAR' }); + const result = await checkEndpointReachability('https://example.test'); + expect(JSON.stringify(result)).not.toContain('secretKey'); + }); +});