Developers

Errors & retries

The SDK has one canonical failure type: BackendError. Every non-2xx response, every transport failure, every aborted fetch surfaces as a BackendError thrown from the method you called. There are no callback-style errors, no return-tuple errors, no null returns — every method either resolves with the typed payload or throws a BackendError.

import { BackendError } from "@stridge/sdk"

try {
  const uda = await stridge.gateway.start(payload)
} catch (err) {
  if (err instanceof BackendError) {
    console.error(err.statusCode, err.message, err.cause)
  }
  throw err
}

BackendError

class BackendError extends Error {
  name: "BackendError"
  message: string             // human-readable; extracted from the gateway payload when possible
  statusCode: number          // HTTP status; 500 for transport-level / unknown failures
  cause?: unknown             // parsed JSON body, raw text, the source Error, or the Response itself
}

The class extends the standard Error (so err.stack works in every runtime), sets its prototype explicitly (so instanceof BackendError survives serialization across worker boundaries), and exposes a BackendError.from(unknown, fallbackMessage?) static for coercing arbitrary thrown values.

How the SDK builds it

When the underlying HttpClient sees a non-2xx response, it constructs a BackendError by walking the response shape in priority order:

SourceResulting BackendError
JSON body with a non-empty message fieldnew BackendError(data.message, status, data)
JSON body with a non-empty error fieldnew BackendError(data.error, status, data)
Any other JSON bodynew BackendError("Request failed with status …", status, data)data holds the parsed body
Plain-text bodynew BackendError(text, status, text)
Unparseable / no bodynew BackendError("Request failed with status …", status, response)

That means err.cause is almost always something useful — the gateway's parsed error envelope, the raw text it returned, or the source Error from the fetch call. For unauthenticated endpoints like uda.quote, cause can be narrowed with a typed guard:

import { BackendError, isQuoteErrorResponse } from "@stridge/sdk"

try {
  const quote = await stridge.uda.quote(input)
} catch (err) {
  if (err instanceof BackendError && isQuoteErrorResponse(err.cause)) {
    // err.cause.error is a QuoteErrorCode — narrow with switch
  }
}

Transport vs backend

Failure modestatusCodecauseRecovery
Backend returned 4xxthe HTTP statusparsed bodyFix the input — most 4xx errors are deterministic (400, 404, 409, 422).
Backend returned 5xxthe HTTP statusparsed bodyRetry with backoff — typically upstream transient.
fetch threw (DNS, TCP, TLS, CORS)500the source ErrorRetry with backoff — runtime / network blip.
AbortSignal aborted500the abort ErrorDon't retry — caller asked for cancellation.
If-None-Match matched (304)304the parsed body if any, else the ResponseTreat as "nothing changed" in your polling loop, not an error.

The collapse of transport errors and "unknown" outcomes into statusCode: 500 is deliberate — it keeps the recovery branch small: "5xx-or-thrown → retry, 4xx → don't, 304 → no-op."

function isTransient(err: unknown): err is BackendError {
  return err instanceof BackendError && err.statusCode >= 500
}

Retry guidance

The SDK ships no built-in retry policy. Every method makes exactly one HTTP call; if it fails, it throws. That's intentional — the SDK can't pick a retry policy that works for every call site (a gateway.start retry is idempotent and cheap, but a gateway.poll retry in a 2s polling loop is wasteful, and uda.quote retries should usually re-fetch a fresh quote not retry the stale request).

The userland pattern that fits most callers is exponential backoff on transient errors, hard fail on deterministic ones. The example below uses no extra dependencies.

import { BackendError } from "@stridge/sdk"

export async function withRetry<T>(
  fn: () => Promise<T>,
  { attempts = 4, baseMs = 250, maxMs = 4_000 }: { attempts?: number; baseMs?: number; maxMs?: number } = {},
): Promise<T> {
  let lastErr: unknown
  for (let attempt = 0; attempt < attempts; attempt++) {
    try {
      return await fn()
    } catch (err) {
      lastErr = err
      const transient = err instanceof BackendError && err.statusCode >= 500
      if (!transient || attempt === attempts - 1) throw err
      const delay = Math.min(maxMs, baseMs * 2 ** attempt) + Math.random() * 100
      await new Promise((r) => setTimeout(r, delay))
    }
  }
  throw lastErr
}

Use it sparingly — wrap calls that should be idempotent and tolerant of a delay, like one-shot provisioning, catalogue fetches, or backfill jobs:

const uda = await withRetry(() => stridge.gateway.start(payload))
const catalogue = await withRetry(() => stridge.gateway.assets())

For polling loops, don't wrap each iteration in withRetry — the loop itself is the retry. Instead, treat a transient BackendError as "skip this tick and try again":

for (;;) {
  try {
    const { udas } = await stridge.gateway.poll(walletAddress)
    const mine = udas.find((u) => u.uda_id === udaId)
    if (mine?.is_terminal) return mine
  } catch (err) {
    if (!(err instanceof BackendError && err.statusCode >= 500)) throw err
    // transient — fall through to the sleep
  }
  await new Promise((r) => setTimeout(r, 2_000))
}

The two patterns compose — withRetry around the outer call that sets up the loop, plain transient-tolerance inside it.

Worked example: settlement-polling loop with idempotency

A complete server-side reconciliation pattern, end to end:

reconcile.ts
import { BackendError, createApiClient } from "@stridge/sdk"

const stridge = createApiClient({
  projectKey: process.env.STRIDGE_PROJECT_KEY!,
  env: "prod",
})

interface PaymentRecord {
  ownerAddress: string
  expectedDestination: { network_id: string; to_address: string; asset_symbol: string }
  sourceTxHash: string
}

export async function reconcile(payment: PaymentRecord, deadlineMs = 15 * 60_000) {
  /** start() is idempotent on (owner, destination), so calling it on every reconcile attempt is safe. */
  await withRetry(() =>
    stridge.gateway.start({
      owner: payment.ownerAddress,
      destination: payment.expectedDestination,
    }),
  )

  const deadline = Date.now() + deadlineMs
  while (Date.now() < deadline) {
    try {
      const { udas } = await stridge.gateway.poll(payment.ownerAddress)

      const match = udas
        .flatMap((u) => u.settlements.map((s) => ({ uda: u, settlement: s })))
        .find(({ settlement }) => settlement.from.tx_id?.toLowerCase() === payment.sourceTxHash.toLowerCase())

      if (match?.uda.is_terminal) {
        return match.settlement.status === "completed" ? "completed" : "failed"
      }
    } catch (err) {
      if (!(err instanceof BackendError && err.statusCode >= 500)) throw err
      /** Transient gateway hiccup — fall through to the sleep and try again. */
    }
    await new Promise((r) => setTimeout(r, 2_000))
  }

  throw new Error(`reconcile deadline exceeded for ${payment.sourceTxHash}`)
}

async function withRetry<T>(fn: () => Promise<T>, attempts = 4): Promise<T> {
  for (let attempt = 0; attempt < attempts; attempt++) {
    try {
      return await fn()
    } catch (err) {
      const transient = err instanceof BackendError && err.statusCode >= 500
      if (!transient || attempt === attempts - 1) throw err
      await new Promise((r) => setTimeout(r, 250 * 2 ** attempt + Math.random() * 100))
    }
  }
  throw new Error("unreachable")
}

The structure splits the two concerns cleanly:

  • Provisioning is wrapped in withRetry because it's idempotent and the caller is willing to wait.
  • Polling is plain transient-tolerant because the outer while is already the retry loop, and a 2s sleep absorbs a gateway hiccup naturally.
  • Termination is is_terminal, not the loop iteration count — the deadline is the only escape hatch.

Coercing unknown errors

If you have a catch block whose error could be anything (a BackendError, a network TypeError, a string thrown from a stale callback), use BackendError.from(err) to normalize:

import { BackendError } from "@stridge/sdk"

try {
  // …
} catch (raw) {
  const err = BackendError.from(raw, "background settlement watcher failed")
  logger.error({ statusCode: err.statusCode, message: err.message, cause: err.cause })
  throw err
}

The static walks the same priority chain as the HttpClient: BackendError → passthrough, Error → wrapped with original message, non-empty string → wrapped as message, anything else → fallback message with the value on cause.

  • API client — header precedence and the two HTTP clients underneath every method.
  • Gateway endpoints — every method that can throw a BackendError.
  • UDA endpointsisQuoteErrorResponse and the typed quote-error code union.
  • Models & types — the BackendError and quote-error type imports.
Was this page helpful?