# Errors, pagination, and HTTP settings

This page describes how the SDKs report errors and how to page through list results. It also covers the HTTP transport settings: retries, timeouts, TLS, proxies, a custom HTTP client, and logging.

The SDK examples assume a configured client. To set one up, see [Integrate via SDK](/products/custody/get-started/integrate-via-sdk).

## Error hierarchy

Every error that the client raises derives from `CustodyError`, so one `catch (CustodyError)` or `except CustodyError` handles any SDK failure. Each SDK represents HTTP errors differently.

TypeScript
```text
CustodyError                      statusCode: HTTP status, or 0 if no response was received
├── ApiError                      status, reason, body, data (parsed JSON body)
│   ├── BadRequestError           400
│   ├── UnauthorizedError         401
│   ├── ForbiddenError            403
│   ├── NotFoundError             404
│   ├── ConflictError             409
│   ├── UnprocessableEntityError  422
│   └── ServiceError              5xx
├── NetworkError                  DNS, connection, TLS, or timeout failure; cause holds the original error
├── ConfigurationError
├── MissingConfigurationError
├── IntentTimeoutError            lastState
├── ResponseSignatureError
└── AddressIntegrityError
```

The SDK raises other 4xx statuses as `ApiError`. It doesn't throw `CustodyWarning`. Instead, it passes the warning message to the logger.

Java
```text
RuntimeException
├── CustodyError
│   ├── IntentTimeoutError        getLastState()
│   ├── ResponseSignatureError
│   └── AddressIntegrityError
└── CustodyWarning
```

Every SDK error is an unchecked exception. Client methods wrap HTTP failures in a `CustodyError` whose cause is the generated `ApiException`. Read the status with `getCode()` and the body with `getResponseBody()`. `getCode()` returns `0` when the SDK received no HTTP response, for example on a connection failure.

If you call the generated `com.ripple.custody.api.*` classes directly, they throw the checked `ApiException` without wrapping it.

Python
```text
CustodyError                         status_code: HTTP status, or 0 if no response was received
├── HTTP errors                      status, reason, body, data, headers
│   ├── CustodyBadRequestError           400
│   ├── CustodyUnauthorizedError         401
│   ├── CustodyForbiddenError            403
│   ├── CustodyNotFoundError             404
│   ├── CustodyConflictError             409
│   ├── CustodyUnprocessableEntityError  422
│   ├── CustodyServiceError              5xx
│   └── CustodyApiError                  any other status
├── ConfigurationError               also a ValueError
│   └── MissingConfigurationError
├── IntentTimeoutError               last_state
├── ResponseSignatureError
└── AddressIntegrityError
```

Each HTTP error class also inherits the matching generated exception, such as `NotFoundException`, so existing `except ApiException` handlers keep working. `CustodyApiError` is a sibling of the status-specific classes, not their parent. To catch every HTTP error, catch `CustodyError` and check `status_code`. The status-specific classes are in `ripple_custody_client_lib_python.custody.exceptions`.

The SDK raises a TLS failure as `CustodyApiError` with `status_code` `0`. For other connection failures and timeouts, urllib3 raises its own exceptions, for example `urllib3.exceptions.MaxRetryError`. These exceptions aren't `CustodyError` subclasses.

Branch on the status code or the error class, never on the message text.

A failed intent isn't an HTTP error. Submitting an intent returns `202 Accepted`, and the platform processes it afterward. If processing fails, `waitAccepted` throws a plain `CustodyError` with status code `0`. See [Work with intents](/products/custody/developer-experience/sdks/intents).

## Catch not-found and conflict errors

The following examples handle `404` and `409` responses when you read a user:

TypeScript
```typescript
import { ConflictError, NotFoundError } from '@ripple/ripple-custody-client-lib-typescript';

try {
  const user = await client.governance.users.get(userId);
} catch (err) {
  if (err instanceof NotFoundError) {
    // The user doesn't exist, or isn't visible to you
  } else if (err instanceof ConflictError) {
    // For example, a duplicate ID or a revision mismatch
  } else {
    throw err;
  }
}
```

Java
```java
// Returns Optional.empty() on 404 instead of throwing
Optional<CoreTrustedUser> user = client.governance().users().find(domainId, userId);

try {
    client.governance().users().get(domainId, userId);
} catch (CustodyError e) {
    int status = e.getCause() instanceof ApiException api ? api.getCode() : 0;
    if (status == 404) {
        // The user doesn't exist, or isn't visible to you
    } else if (status == 409) {
        // For example, a duplicate ID or a revision mismatch
    } else {
        throw e;
    }
}
```

Python
```python
from ripple_custody_client_lib_python import CustodyError

try:
    user = client.governance.users.get(user_id)
except CustodyError as exc:
    if exc.status_code == 404:
        pass  # The user doesn't exist, or isn't visible to you
    elif exc.status_code == 409:
        pass  # For example, a duplicate ID or a revision mismatch
    else:
        raise
```

## Pagination

List methods return one page by default. A page has `items` and a `nextStartingAfter` cursor. To get the next page, pass the cursor as `startingAfter`. When the cursor is empty, there are no more pages.

Each SDK also has a way to read all pages:

- **TypeScript:** pass `autoPaginate: true`. The method returns an array of every item instead of a page. It stops with `CustodyError` after 1,000 pages.
- **Java:** call `stream(...)` (or `streamOrders`, `streamTransactions`, and similar). The stream fetches pages lazily as you consume it. For list methods without a stream variant, build one with `AutoPaginate.stream(...)`.
- **Python:** pass `auto_paginate=True`. The method returns a list of every item instead of a page.


In TypeScript and Python, automatic pagination loads every item into memory before it returns. For large result sets, page manually.

The following examples show both approaches:

TypeScript
```typescript
import type {
  CoreTrustedDomain,
  CoreTrustedDomainsCollection,
} from '@ripple/ripple-custody-client-lib-typescript';

// One page at a time
let cursor: string | undefined;
do {
  const page = (await client.governance.domains.list({
    limit: 50,
    startingAfter: cursor,
  })) as CoreTrustedDomainsCollection;
  for (const domain of page.items) console.log(domain.data.id);
  cursor = page.nextStartingAfter || undefined;
} while (cursor);

// Every page at once
const all = (await client.governance.domains.list({
  autoPaginate: true,
})) as CoreTrustedDomain[];
```

Java
```java
// One page at a time
UUID cursor = null;
do {
    CoreTrustedDomainsCollection page = client.governance().domains().list(50, cursor);
    page.getItems().forEach(domain -> System.out.println(domain.getData().getId()));
    String next = page.getNextStartingAfter();
    cursor = next == null || next.isBlank() ? null : UUID.fromString(next);
} while (cursor != null);

// Every page, fetched lazily
client.governance().domains().stream()
    .forEach(domain -> System.out.println(domain.getData().getId()));
```

Python
```python
# One page at a time
cursor = None
while True:
    page = client.governance.domains.list(limit=50, starting_after=cursor)
    for domain in page.items or []:
        print(domain.data.id)
    cursor = page.next_starting_after
    if not cursor:
        break

# Every page at once
all_domains = client.governance.domains.list(auto_paginate=True)
```

For a complete program, see example `01` (listing and pagination). To get the examples, see [Examples](/products/custody/developer-experience/sdks#examples).

## Inject a custom HTTP client

Each SDK lets you supply your own HTTP transport. Use this to add retries, timeouts, a proxy, tracing headers, or request logging. When you supply a transport, it owns the connection. The SDK's TLS settings (`RC_VERIFY_SSL`, `RC_TLS_MIN_VERSION`, `RC_SSL_CA_CERT`, `RC_CA_CERT_DATA`, `RC_CERT_FILE`, and `RC_KEY_FILE`) no longer apply, so configure TLS on your transport. Response-signature verification still applies.

TypeScript
Pass a `fetchApi` function to `generateNewConfiguration` and use the result as the client's transport:

```typescript
import {
  CustodyClient,
  generateNewConfiguration,
  loadConfiguration,
} from '@ripple/ripple-custody-client-lib-typescript';

const config = loadConfiguration();

const myFetch: typeof fetch = async (url, init) => fetch(url, init);

const sdkConfig = generateNewConfiguration({
  apiBaseUrl: config.apiBaseUrl,
  oidcIssuerUrl: config.oidcIssuerUrl,
  tokenEndpoint: config.tokenEndpoint,
  readPublicKey: config.readPublicKey,
  signString: config.signString,
  verifyResponses: config.verifyResponses,
  notaryApiPublicKey: config.notaryApiPublicKey,
  fetchApi: myFetch,
});

const client = new CustodyClient({ ...config, _sdkConfig: sdkConfig });
```

`generateNewConfiguration` doesn't read `config.toml`, so pass it the values that `loadConfiguration()` returns, as shown. `loadConfiguration()` doesn't return the client ID or client secret. For the client-credentials grant, also pass `oAuthServerClientId` and `oAuthServerClientSecret`.

OAuth token requests use Node's global `fetch`, not `fetchApi`. A custom transport, including any proxy you configure on it, doesn't apply to them.

Java
Pass an `OkHttpClient` to `ApiClient.newBuilder().httpClient(...)`. `CustodyClient.fromConfig` has no hook for this, so build the `ApiClient` yourself. Start from the SDK's hardened default client to keep its TLS 1.2 minimum:

```java
import com.ripple.custody.CustodyClientConfig;
import com.ripple.custody.auth.SignerFactory;
import com.ripple.custody.invoker.ApiClient;
import com.ripple.custody.transport.OkHttpClientAdapter;
import java.util.concurrent.TimeUnit;
import okhttp3.OkHttpClient;

OkHttpClient httpClient = OkHttpClientAdapter.defaultHardenedClient().newBuilder()
    .retryOnConnectionFailure(false)
    .connectTimeout(10, TimeUnit.SECONDS)
    .readTimeout(30, TimeUnit.SECONDS)
    .build();

CustodyClientConfig config = CustodyClientConfig.loadConfiguration();
SignerFactory.Signers signers = SignerFactory.fromConfig(config);

ApiClient apiClient = ApiClient.newBuilder()
    .tokenEndpoint(config.getEffectiveTokenEndpoint())
    .clientId(config.getClientId() != null ? config.getClientId() : "customer_api")
    .grantType("password")
    .signer(signers.getChallengeSigner())
    .httpClient(httpClient)
    .build();
apiClient.setBasePath(config.getApiBaseUrl());
config.apiClient(apiClient).signer(signers.getIntentSigner());

CustodyClient client = new CustodyClient(config);
```

The injected client also sends OAuth token requests. This example disables OkHttp's automatic retries after connection failures. See [Retries](#retries) for the default behavior.

Python
Pass a `urllib3.PoolManager` (or `ProxyManager`) as `pool_manager`. Set the TLS minimum on it yourself. The SDK warns if an injected pool accepts a lower TLS version than its configured minimum or disables certificate verification. Set the request timeout on `Configuration`, not just on the pool. The following example sets a 30-second request timeout, disables urllib3 retries, and sets a TLS 1.2 minimum:

```python
import ssl

import urllib3
from ripple_custody_client_lib_python import CustodyClient, load_configuration

config = load_configuration()
config.timeout = 30.0
pool = urllib3.PoolManager(
    retries=False,
    ssl_minimum_version=ssl.TLSVersion.TLSv1_2,
)

try:
    with CustodyClient(config, pool_manager=pool) as client:
        ...
finally:
    pool.clear()
```

The SDK passes a timeout on each API request, overriding the pool's default. If you leave `config.timeout` unset, the SDK passes `None`. Setting a timeout only on `PoolManager` doesn't protect those requests. The SDK doesn't close an injected pool, so the example clears it in `finally`. OAuth token requests don't use the pool. They use the timeout, proxy, and TLS settings on `Configuration`.

## Retries

The SDK or its HTTP transport can retry a request. The defaults differ by language:

| SDK | Default behavior |
|  --- | --- |
| TypeScript | Doesn't retry. A `401` response throws `UnauthorizedError`. |
| Java | Uses OkHttp's automatic retries after connection failures. |
| Python (synchronous client) | Uses urllib3's default retry policy. |


The language tabs below explain how to configure retries. The Python settings apply to the synchronous `CustodyClient` and urllib3, not an injected asynchronous transport. OAuth token requests can use a different transport, as described in [Inject a custom HTTP client](#inject-a-custom-http-client).

If you add retries for temporary failures, restrict them to `GET` requests. Account for any retries that the HTTP transport already makes. Don't automatically retry `POST` requests that submit, approve, or reject intents. Ripple Custody might already have accepted a request that timed out. Before you resubmit after an unclear failure, list recent intents and check whether the first one arrived.

The following examples retry only `GET` requests that fail with a transient status:

TypeScript
The SDK doesn't retry requests, including after a `401` response. To retry, add retries through a custom `fetchApi`. The example below adds retries for `429` and `5xx` responses on `GET` requests.

```typescript
const retryingFetch: typeof fetch = async (url, init) => {
  const method = init?.method ?? 'GET';
  for (let attempt = 1; ; attempt++) {
    const response = await fetch(url, init);
    const transient = response.status === 429 || response.status >= 500;
    if (method !== 'GET' || !transient || attempt === 3) return response;
    await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt));
  }
};
```

Pass `retryingFetch` as `fetchApi`, as shown in [Inject a custom HTTP client](#inject-a-custom-http-client).

Java
OkHttp can retry requests after connection failures, including non-`GET` requests. It doesn't retry every HTTP error status. Set `.retryOnConnectionFailure(false)` on an injected client to disable connection retries. Also review any interceptors and OkHttp settings that can send extra requests, such as redirects.

```java
import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.Response;

Interceptor retryReads = chain -> {
    Request request = chain.request();
    Response response = chain.proceed(request);
    for (int attempt = 1; attempt < 3 && "GET".equals(request.method())
            && (response.code() == 429 || response.code() >= 500); attempt++) {
        response.close();
        response = chain.proceed(request);
    }
    return response;
};
```

Add the interceptor with `.addInterceptor(retryReads)` to the injected `OkHttpClient` above. Keep `.retryOnConnectionFailure(false)` so connection retries don't bypass this `GET`-only policy.

Python
`config.retries` defaults to `None`, which leaves urllib3's default retry policy in place. urllib3 can retry requests after connection failures, including non-`GET` requests. Its policy also controls retries after read errors and HTTP error responses.

For a pool that the SDK creates, set `config.retries = 0` to disable retries. For an injected pool, set `retries=False` or supply a `urllib3.Retry` policy on the pool. `config.retries` doesn't configure an injected pool. The following policy retries only `GET` requests with the listed status codes:

```python
import ssl

import urllib3
from ripple_custody_client_lib_python import CustodyClient, load_configuration

config = load_configuration()
retry = urllib3.Retry(
    total=3,
    connect=0,
    read=0,
    other=0,
    redirect=0,
    backoff_factor=0.5,
    status_forcelist={429, 500, 502, 503, 504},
    allowed_methods={"GET"},
    raise_on_status=False,
)
pool = urllib3.PoolManager(
    retries=retry,
    ssl_minimum_version=ssl.TLSVersion.TLSv1_2,
)
client = CustodyClient(config, pool_manager=pool)
```

`allowed_methods` alone doesn't prevent connection retries for `POST`. This policy also disables retries after connection failures, read errors, other errors, and redirects. It retries only `GET` requests with the specified status codes.

## Timeouts

HTTP timeouts limit a single request. They're separate from the timeouts you pass to `waitAccepted` and the other intent wait methods, which limit how long the SDK keeps polling. See [Work with intents](/products/custody/developer-experience/sdks/intents#timeouts-and-intenttimeouterror).

- **TypeScript:** the built-in transport sets no request timeout of its own. Token requests time out after 30 seconds. A request that times out throws `NetworkError`. To limit API requests, use a custom `fetchApi` that sets an abort signal:

```typescript
const fetchWithTimeout: typeof fetch = (url, init) =>
  fetch(url, { ...init, signal: AbortSignal.timeout(30_000) });
```
- **Java:** the default client uses the OkHttp defaults of 10 seconds for connect, read, and write. To change them, set them on a custom `OkHttpClient`, as shown in [Inject a custom HTTP client](#inject-a-custom-http-client).
- **Python:** set `config.timeout` to a number of seconds before you create the client. It applies to API requests and token requests, even when you inject a custom pool. A timeout set only on the pool doesn't protect API requests because the SDK overrides it.


## TLS settings

The SDKs verify the server certificate by default. The TypeScript SDK also rejects an `http://` API base URL unless the host is local. You configure the following settings in `config.toml` or with `RC_*` environment variables. For the key names and formats, see [Configure the SDK](/products/custody/developer-experience/sdks/configuration).

| Setting | Environment variable | Notes |
|  --- | --- | --- |
| Minimum TLS version | `RC_TLS_MIN_VERSION` | TypeScript and Python. Default `TLSv1_2`. Set `TLSv1_3` to require TLS 1.3. The Java SDK always accepts TLS 1.2 and 1.3 and has no setting. |
| Custom CA bundle | `RC_SSL_CA_CERT` (file path) or `RC_CA_CERT_DATA` (inline PEM) | Use when a private certificate authority (CA) issues your deployment's certificate. |
| Mutual TLS | `RC_CERT_FILE` and `RC_KEY_FILE` | Set both or neither. |
| Certificate verification | `RC_VERIFY_SSL` | Default `true`. Set `false` only for local development. |


These settings apply only to the transport the SDK builds. If you inject your own HTTP client, configure TLS on it.

## Proxy

How you route requests through a proxy differs by SDK.

TypeScript
The SDK has no proxy setting. Install `undici` in your project, and route API requests through a proxy with a custom `fetchApi` that uses undici's `ProxyAgent`:

```typescript
import { ProxyAgent, fetch as undiciFetch } from 'undici';

const proxy = new ProxyAgent('http://proxy.example.com:3128');
const proxiedFetch: typeof fetch = (input, init) =>
  undiciFetch(input as Parameters<typeof undiciFetch>[0], {
    ...(init as Parameters<typeof undiciFetch>[1]),
    dispatcher: proxy,
  }) as unknown as Promise<Response>;
```

Pass `proxiedFetch` as `fetchApi`. Token requests don't go through `fetchApi`, so this doesn't proxy them.

Java
Set the proxy on a custom `OkHttpClient`:

```java
import com.ripple.custody.transport.OkHttpClientAdapter;
import java.net.InetSocketAddress;
import java.net.Proxy;
import okhttp3.OkHttpClient;

OkHttpClient httpClient = OkHttpClientAdapter.defaultHardenedClient().newBuilder()
    .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("proxy.example.com", 3128)))
    .build();
```

Pass it to `ApiClient.newBuilder().httpClient(...)`, as shown in [Inject a custom HTTP client](#inject-a-custom-http-client). Token requests use the same client.

Python
The proxy setting applies to API requests and token requests. The SDK also supports SOCKS proxy URLs, such as `socks5://`. Set the proxy on the configuration before you create the client:

```python
from ripple_custody_client_lib_python import CustodyClient, load_configuration

config = load_configuration()
config.proxy = "http://proxy.example.com:3128"

client = CustodyClient(config)
```

## Logging

Each SDK handles logging differently.

TypeScript
The SDK logs nothing by default. Set `RC_LOG_LEVEL_NPM` (for example, `warn` or `debug`) to log to the console, or route SDK logs to your own logger with `setLogger`:

```typescript
import { setLogger } from '@ripple/ripple-custody-client-lib-typescript';

setLogger({
  debug: () => {},
  info: (msg) => console.info(msg),
  warn: (msg) => console.warn(msg),
  error: (msg, err) => console.error(msg, err),
});
```

Call `setLogger` before you create the client.

Java
The SDK writes its warnings, such as a warning that certificate verification is off, through `java.util.logging` (JUL) under the `com.ripple.custody` logger names. Configure JUL the same way you do for the rest of your application.

To log HTTP traffic, add OkHttp's `HttpLoggingInterceptor` to a custom `OkHttpClient`. Use the `BASIC` level in production: the `HEADERS` and `BODY` levels write access tokens and payloads to the log.

Python
The SDK logs through the standard `logging` module under the `ripple_custody_client_lib_python` logger, which has only a `NullHandler` by default. HTTP-level messages come from the `urllib3` logger. For example, to log SDK messages at `INFO` level:

```python
import logging

logging.basicConfig(level=logging.WARNING)
logging.getLogger("ripple_custody_client_lib_python").setLevel(logging.INFO)
```

## Related topics

- [Configure the SDK](/products/custody/developer-experience/sdks/configuration): configuration keys and environment variables, including the TLS settings.
- [Work with intents](/products/custody/developer-experience/sdks/intents): submit intents and wait for their outcome with `PendingIntent`.
- [Verify response signatures](/products/custody/developer-experience/sdks/response-verification): pin the notary API public key and handle `ResponseSignatureError`.