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
157 changes: 157 additions & 0 deletions src/network/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PocketPayError } from '../types';
import { wrapError } from '../utils';

const FALLBACK_TIMEOUT_MS = 30_000;

Expand Down Expand Up @@ -72,3 +73,159 @@ export async function fetchWithTimeout(
init?.signal?.removeEventListener('abort', abortFromCaller);
}
}

/**
* Typed network client for making HTTP requests with consistent error handling,
* timeout management, and response parsing.
*/
export class NetworkClient {
private readonly baseUrl: string | undefined;
private readonly defaultTimeoutMs: number;

constructor(options?: { baseUrl?: string; defaultTimeoutMs?: number }) {
this.baseUrl = options?.baseUrl;
this.defaultTimeoutMs = options?.defaultTimeoutMs ?? FALLBACK_TIMEOUT_MS;
}

/**
* Performs a GET request and parses the JSON response.
*/
async get<T>(
path: string,
options?: {
timeoutMs?: number;
headers?: Record<string, string>;
operation?: string;
},
): Promise<T> {
return this.request<T>(path, {
method: 'GET',
...options,
});
}

/**
* Performs a POST request with JSON body and parses the JSON response.
*/
async post<T>(
path: string,
body?: unknown,
options?: {
timeoutMs?: number;
headers?: Record<string, string>;
operation?: string;
},
): Promise<T> {
return this.request<T>(path, {
method: 'POST',
body,
...options,
});
}

/**
* Performs a generic HTTP request with JSON parsing and error handling.
*/
private async request<T>(
path: string,
options: {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: unknown;
timeoutMs?: number;
headers?: Record<string, string>;
operation?: string;
},
): Promise<T> {
const url = this.baseUrl ? `${this.baseUrl}${path}` : path;
const operation = options.operation || `${options.method} ${path}`;
const timeoutMs = options.timeoutMs ?? this.defaultTimeoutMs;

const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers,
};

const init: RequestInit = {
method: options.method,
headers,
};

if (options.body !== undefined) {
init.body = JSON.stringify(options.body);
}

try {
const response = await fetchWithTimeout(
url,
init,
operation,
timeoutMs,
);

if (!response.ok) {
let errorBody: unknown;
try {
errorBody = await response.json();
} catch {
errorBody = await response.text();
}
throw new PocketPayError(
`${operation} failed with status ${response.status}`,
`HTTP_ERROR_${response.status}`,
response.status,
);
}

return (await response.json()) as T;
} catch (error) {
if (error instanceof PocketPayError) {
throw error;
}
throw wrapError(error, operation, 'NETWORK_ERROR');
}
}
}

/**
* Executes a Horizon server operation with timeout and consistent error handling.
*/
export async function executeHorizonOperation<T>(
operation: string,
timeoutMs: number | undefined,
fn: () => Promise<T>,
): Promise<T> {
try {
return await withTimeout(operation, timeoutMs, fn());
} catch (error) {
if (error instanceof PocketPayError) {
throw error;
}
const horizonError = error as any;
if (horizonError?.response?.status === 404) {
throw new PocketPayError(
'Resource not found',
'NOT_FOUND',
404,
);
}
throw wrapError(error, operation, 'HORIZON_ERROR');
}
}

/**
* Executes a Soroban RPC operation with timeout and consistent error handling.
*/
export async function executeSorobanOperation<T>(
operation: string,
timeoutMs: number | undefined,
fn: () => Promise<T>,
): Promise<T> {
try {
return await withTimeout(operation, timeoutMs, fn());
} catch (error) {
if (error instanceof PocketPayError) {
throw error;
}
throw wrapError(error, operation, 'SOROBAN_ERROR');
}
}
38 changes: 22 additions & 16 deletions src/wallet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from '../types';
import { validatePublicKey, validateSecretKey, wrapError, toResult, toEnhancedSuccessResult, toEnhancedFailureResult, toEnhancedResult } from '../utils';
import type { ResultWarning, RecoveryHint } from '../errors';
import { fetchWithTimeout, withTimeout } from '../network';
import { NetworkClient, withTimeout } from '../network';

/**
* Creates a new random Stellar keypair.
Expand Down Expand Up @@ -216,21 +216,16 @@ export async function fundTestnetAccount(
);
}
try {
const resp = await fetchWithTimeout(
`${getFriendbotUrl()}?addr=${encodeURIComponent(publicKey)}`,
undefined,
'Friendbot funding request',
cfg.timeout,
const client = new NetworkClient({
baseUrl: getFriendbotUrl(),
defaultTimeoutMs: cfg.timeout,
});
const data = await client.get<Record<string, unknown>>(
`?addr=${encodeURIComponent(publicKey)}`,
{
operation: 'Friendbot funding request',
},
);
if (!resp.ok) {
const body = await resp.text().catch(() => '(no body)');
throw new PocketPayError(
`Friendbot HTTP ${resp.status}: ${body}`,
'FRIENDBOT_ERROR',
resp.status,
);
}
const data = (await resp.json()) as Record<string, unknown>;
return {
success: true,
publicKey,
Expand All @@ -242,7 +237,18 @@ export async function fundTestnetAccount(
friendbotAccount: typeof data['source_account'] === 'string' ? data['source_account'] : undefined,
};
} catch (error) {
if (error instanceof PocketPayError) throw error;
if (error instanceof PocketPayError) {
// Map general HTTP errors to Friendbot-specific error code
if (error.code.startsWith('HTTP_ERROR_')) {
throw new PocketPayError(
error.message,
'FRIENDBOT_ERROR',
error.httpStatus,
error.cause,
);
}
throw error;
}
throw wrapError(error, 'Failed to fund testnet account', 'FUND_ERROR');
}
}
Expand Down