> ## Documentation Index
> Fetch the complete documentation index at: https://docs.omophub.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Error handling and retries

> Handle OMOPHub API errors gracefully from the Node.js SDK - discriminated `{ data, error }` returns, retry behaviour, idempotency keys, and async-iterator failure handling.

## Errors Are Return Values

Unlike SDKs that throw on API errors, every method in `@omophub/omophub-node` returns a discriminated `{ data, error, meta, headers }` union. Network failures, 4xx, and 5xx all surface as `{ data: null, error, headers }`. TypeScript narrows `data` to the success type in the `else` branch:

```ts theme={null}
const { data, error } = await client.concepts.get(999_999_999);
if (error) {
  console.error(error.name, error.message, error.statusCode);
  return;
}
// `data` is now typed as Concept
console.log(data.concept_name);
```

The only thing the SDK throws is `OMOPHubError` on **constructor misuse** (missing API key) and `OMOPHubIteratorError` from async iterators (see [below](#async-iterators)).

## Error Shape

```ts theme={null}
interface ErrorResponse {
  name: OMOPHUB_ERROR_CODE_KEY;     // stable code suitable for switch statements
  message: string;
  statusCode: number | null;        // HTTP status, or null for client-side errors
  requestId?: string;               // from x-request-id header
  retryAfter?: number;              // seconds, populated on 429 / 503 / 502 / 504
  details?: Record<string, unknown>;
}
```

## Error Codes

| Code                                                             | Cause     |
| ---------------------------------------------------------------- | --------- |
| `missing_api_key`, `invalid_api_key`, `restricted_api_key`       | 401 / 403 |
| `not_found`                                                      | 404       |
| `validation_error`, `missing_required_field`, `invalid_argument` | 400       |
| `method_not_allowed`, `conflict`                                 | 405 / 409 |
| `rate_limit_exceeded`, `tier_limit_exceeded`                     | 429       |
| `service_unavailable`, `internal_server_error`                   | 5xx       |
| `connection_error`, `timeout_error`                              | Transport |
| `application_error`                                              | Catch-all |

Body-supplied error codes (from `error.code` or `error.name` in the response body) **win over** the generic status mapping - so a 400 carrying `{ error: { code: 'missing_required_field' } }` surfaces as `missing_required_field`, not the generic `validation_error`.

## Narrowing by Code

```ts theme={null}
const { data, error } = await client.concepts.get(999_999_999);
if (error) {
  switch (error.name) {
    case 'not_found':
      console.log('Concept missing - falling back to source vocabulary');
      break;
    case 'rate_limit_exceeded':
      console.log(`Retry after ${error.retryAfter}s`);
      break;
    case 'invalid_api_key':
      throw new Error('Refresh your OMOPHub API key');
    default:
      console.error(error.message, error.requestId);
  }
}
```

## Synthetic Validation Errors

The SDK validates a few invariants client-side before issuing the request - `concepts.batch` (1–100 ids), `mappings.map` (exactly one of `sourceConcepts`/`sourceCodes`, non-empty), `search.similar` (exactly one of `conceptId`/`conceptName`/`query`), `fhir.resolveBatch` (1–100 codings), `fhir.resolveCodeableConcept` (1–20 codings).

These return a synthetic error with `statusCode: null` and never hit the network:

```ts theme={null}
const { error } = await client.concepts.batch({ conceptIds: [] });
console.log(error?.name);       // 'validation_error'
console.log(error?.statusCode); // null
```

## Retries

The client automatically retries on:

* HTTP 429, 502, 503, 504
* Transient network errors (DNS failures, connection drops)

Backoff is full-jitter exponential: `min(500 * 2^attempt, 8000) * (1 - 0.25 * random())`. The `Retry-After` header is honoured up to a 60 s cap and floored at 100 ms (so `Retry-After: 0` doesn't become a spam-retry).

```ts theme={null}
const client = new OMOPHub('oh_xxx', {
  maxRetries: 3,    // default
  timeoutMs: 30_000,
});

// Disable retries entirely
const noRetry = new OMOPHub('oh_xxx', { maxRetries: 0 });
```

### POST/PATCH Idempotency

To prevent accidental duplicate writes on retry, **POST and PATCH only retry when an `Idempotency-Key` is set**. Idempotent verbs (GET/HEAD/OPTIONS/PUT/DELETE) always retry.

```ts theme={null}
// Will NOT retry on 503 - POST without an idempotency key
await client.concepts.batch({ conceptIds: [1, 2] });

// WILL retry on 503 - opt in via idempotencyKey
await client.concepts.batch({
  conceptIds: [1, 2],
  idempotencyKey: 'my-pipeline-run-2026-05-30-batch-1',
});
```

## Timeout

The per-request timeout defaults to 30 s. On expiry the SDK returns a `timeout_error` (it does **not** retry timeouts):

```ts theme={null}
const { error } = await client.search.basic('diabetes');
if (error?.name === 'timeout_error') {
  // Either the network stalled or the server was too slow
}

// Disable
const noTimeout = new OMOPHub('oh_xxx', { timeoutMs: 0 });
```

## AbortSignal

Pass an `AbortSignal` via the per-call options to cancel a request mid-flight. **Caller-initiated aborts re-throw** as `AbortError` - they're a deliberate cancellation, not an API error:

```ts theme={null}
const controller = new AbortController();
setTimeout(() => controller.abort(), 100);

try {
  await client.search.basic('diabetes', { signal: controller.signal });
} catch (e) {
  if (e instanceof Error && e.name === 'AbortError') {
    console.log('User cancelled');
  }
}
```

## Async Iterators

`*Iter` variants (`search.basicIter`, `search.semanticIter`) are async generators. Generators can't gracefully yield discriminated errors, so they **throw `OMOPHubIteratorError`** on page failure:

```ts theme={null}
import { OMOPHubIteratorError } from '@omophub/omophub-node';

try {
  for await (const concept of client.search.basicIter('diabetes')) {
    console.log(concept.concept_id);
  }
} catch (e) {
  if (e instanceof OMOPHubIteratorError) {
    console.error(`Page failed (${e.code}, status ${e.statusCode}): ${e.message}`);
  }
}
```

Prefer the eager `*All` variant if you want errors as values:

```ts theme={null}
const { data, errors, pagesFetched } = await client.search.basicAll('diabetes', {
  pageSize: 100,
  maxPages: 10,
});

console.log(`Collected ${data.length} concepts across ${pagesFetched} pages`);
if (errors.length > 0) {
  console.warn('Partial result:', errors);
}
```

## Headers and Request IDs

Every response - success or error - includes the wire headers as a plain `Record<string, string>`:

```ts theme={null}
const { headers } = await client.concepts.get(201826);
console.log(headers?.['x-request-id']);
console.log(headers?.['x-ratelimit-remaining']);
```

`error.requestId` is also populated automatically for support ticket triage.

## Custom Fetch (Proxy / Instrumentation)

Pass your own `fetch` implementation for proxying, logging, or test injection:

```ts theme={null}
const instrumented: typeof fetch = async (url, init) => {
  console.log(`→ ${init?.method ?? 'GET'} ${url}`);
  const t0 = Date.now();
  const response = await fetch(url, init);
  console.log(`← ${response.status} (${Date.now() - t0}ms)`);
  return response;
};

const client = new OMOPHub('oh_xxx', { fetch: instrumented });
```

## Best Practices

### Switch on `error.name`, Not Strings

```ts theme={null}
if (error?.name === 'rate_limit_exceeded') {
  await sleep((error.retryAfter ?? 1) * 1000);
  // retry
}
```

### Log `error.requestId` for Support

```ts theme={null}
if (error) {
  logger.warn('OMOPHub error', {
    code: error.name,
    requestId: error.requestId,
    statusCode: error.statusCode,
  });
}
```

### Set Idempotency Keys for Write-Like POSTs

```ts theme={null}
await client.mappings.map({
  targetVocabulary: 'SNOMED',
  sourceConcepts: [201826],
  idempotencyKey: `${runId}-${batchIndex}`,
});
```
