Error Recovery Patterns

How to handle gecx-chat errors in production React applications. This is a companion to:

This guide focuses on patterns: real code you can copy into your app for each error category.


Quick reference

Error CategoryExample CodesRecovery PatternSection
AuthenticationAUTH_EXPIRED, AUTH_FAILED, TOKEN_ENDPOINT_FAILEDToken refresh + retry§1
Transport & streamingTRANSPORT_CONNECT_FAILED, STREAM_INTERRUPTEDBuilt-in automatic recovery§2
NetworkNETWORK_OFFLINE, RECONNECT_EXHAUSTEDOffline UX + manual retry§3
ToolsTOOL_EXECUTION_FAILED, TOOL_TIMEOUTTool-specific retry UX§4
SessionsSESSION_EXPIRED, SESSION_ENDEDNew session + draft preservation§5
UploadsFILE_VALIDATION_FAILED, UPLOAD_FAILEDInline error + retry button§6

1. Authentication errors

The SDK refreshes tokens automatically when an AuthProvider is configured with a token endpoint. You only need to write recovery code for two cases: when the refresh itself fails, and when credentials are rejected outright.

import { ChatSdkError } from 'gecx-chat';
import { useChatSession } from 'gecx-chat/react';

function ChatComponent() {
  const { send, error, status } = useChatSession({
    config: { auth: tokenEndpointAuth({ url: '/api/chat-token' }) },
    onError: (err) => {
      if (!(err instanceof ChatSdkError)) return;

      switch (err.code) {
        case 'AUTH_EXPIRED':
          // SDK retries automatically. Nothing to do.
          break;

        case 'AUTH_FAILED':
        case 'TOKEN_ENDPOINT_INVALID_RESPONSE':
          // Hard failure — user needs to re-authenticate. Redirect to login,
          // preserving the current path so we can return after sign-in.
          window.location.href = `/login?next=${encodeURIComponent(window.location.pathname)}`;
          break;

        case 'TOKEN_ENDPOINT_FAILED':
          // Network error reaching the token endpoint. The SDK already retries
          // with backoff. Surface a transient banner only if it persists.
          break;
      }
    },
  });

  return <ChatSurface status={status} error={error} send={send} />;
}

What the SDK does for you:

  • Auto-refreshes tokens before they expire (per prefetchBufferMs)
  • Retries token endpoint failures with exponential backoff
  • Marks AUTH_EXPIRED as retryable: true so retry helpers can act on it

What you must do:

  • Handle the unrecoverable case (AUTH_FAILED) by routing to your sign-in flow
  • Decide your UX for repeated TOKEN_ENDPOINT_FAILED (banner, toast, or silent)

2. Transport & streaming recovery

The SDK handles transport errors automatically via the configurable RecoveryPolicy. You rarely need to write recovery code — instead, configure the policy to match your reliability needs.

const client = createChatClient({
  transport: createProxyTransport({ endpoint: '/chat/stream' }),
  recovery: {
    maxAttempts: 5,           // total reconnect attempts before giving up
    initialBackoffMs: 500,    // first retry after 500ms
    maxBackoffMs: 30_000,     // cap each backoff at 30s
    backoffMultiplier: 1.5,   // grow backoff by 1.5x each attempt
    resumeMode: 'resume',     // 'resume' if transport supports it, else 'replay'
  },
});

Status transitions the user sees during recovery:

ready → streaming → disconnected → recovering → ready

Render an inline indicator when status === 'recovering':

function StatusBanner({ status }: { status: SessionStatus }) {
  if (status === 'recovering') {
    return <div role="status">Reconnecting…</div>;
  }
  if (status === 'disconnected') {
    return <div role="alert">Connection lost. Retrying…</div>;
  }
  return null;
}

What the SDK does for you:

  • Reconnects with exponential backoff
  • Resumes streams from the last cursor when transport supports it (resumeMode: 'resume')
  • Replays the in-flight turn when resume is unavailable (resumeMode: 'replay')
  • Deduplicates with idempotency keys so replay is safe
  • Emits RECONNECT_EXHAUSTED only after maxAttempts failures

What you must do:

  • Show a recovery banner so the user understands the delay
  • Handle RECONNECT_EXHAUSTED (see §3 — it falls into the network category)

3. Network errors

When the device is offline or recovery exhausts, the SDK exposes status changes you can react to.

function OfflineBanner() {
  const { status, error, session } = useChatSession({ /* ... */ });

  if (status === 'recovering' || status === 'disconnected') {
    return (
      <div role="alert" className="banner">
        <span>You're offline. Messages will send when connection returns.</span>
        <button onClick={() => session?.reconnect()}>Retry now</button>
      </div>
    );
  }

  if (error instanceof ChatSdkError && error.code === 'RECONNECT_EXHAUSTED') {
    return (
      <div role="alert" className="banner banner--error">
        <span>Couldn't reconnect. Check your connection and try again.</span>
        <button onClick={() => window.location.reload()}>Reload</button>
      </div>
    );
  }

  return null;
}

What the SDK does for you:

  • Detects navigator.onLine and queues sends in the offline outbox (when network: 'auto')
  • Drains the outbox automatically when connectivity returns
  • Emits OUTBOX_DROPPED if a queued message is too old to send

What you must do:

  • Show an offline banner so the user knows their messages are queued, not lost
  • Provide a manual "Retry now" affordance for impatient users
  • Handle OUTBOX_DROPPED if you want to surface dropped queued messages

4. Tool errors

Tool errors come in two flavors: pre-execution (validation, approval denied) and execution-time (timeout, runtime failure). The recovery UX is different for each.

function ToolApprovalDialog() {
  const { sendToolResponse, messages } = useChatSession({ /* ... */ });

  // Pre-execution: validation or approval denied. The tool never ran.
  // Surface a retry that lets the user adjust their input.
  function handleValidationFailed(toolCallId: string, code: string) {
    if (code === 'TOOL_VALIDATION_FAILED') {
      // Show the schema error to the user; let them edit and resubmit.
      // sendToolResponse with output: { ok: false, retry: true }
    }
  }

  // Execution-time: tool ran but timed out or threw.
  // Most tools should be safe to retry; check sideEffectLevel before assuming.
  function handleExecutionFailed(toolCallId: string, code: string) {
    if (code === 'TOOL_TIMEOUT') {
      return (
        <div>
          The tool took too long. <button onClick={() => retry(toolCallId)}>Retry</button>
        </div>
      );
    }
    if (code === 'TOOL_EXECUTION_FAILED') {
      // Don't auto-retry — the failure may be deterministic. Show the user.
      return <div>The tool failed. Try a different approach.</div>;
    }
  }
}

For server tools with side effects: Idempotency keys make retries safe. Verify the tool's idempotency.mode is 'strict' or 'replay' before retrying state-changing tools.

defineServerTool({
  name: 'place_order',
  // ...
  idempotency: {
    mode: 'strict',           // duplicate idempotencyKey returns the original result
    ttlMs: 24 * 60 * 60_000,  // remember keys for 24 hours
    duplicateBehavior: 'replayed',
  },
  sideEffectLevel: 'state_changing',
});

What the SDK does for you:

  • Validates tool input against the JSON schema before execution
  • Enforces the timeoutMs per tool
  • Routes errors through the tool registry's lifecycle events

What you must do:

  • Decide retry semantics per tool based on sideEffectLevel
  • Show validation errors to the user so they can fix their input
  • For state-changing tools, ensure idempotency is configured before enabling retry

5. Session expiry

Sessions can end for two reasons: TTL expiry (SESSION_EXPIRED) or explicit termination (SESSION_ENDED). Both leave the user mid-conversation, so preserve their draft input before starting a new session.

function ChatWithRecovery() {
  const { send, input, status, reset } = useChatSession({ /* ... */ });
  const [pendingDraft, setPendingDraft] = useState<string | null>(null);

  // When a session ends, preserve the user's draft for the new session.
  useEffect(() => {
    if (status === 'expired') {
      const draft = input.value;
      input.clear();
      setPendingDraft(draft);
    }
  }, [status, input]);

  // After reset completes and we're back to ready, restore the draft.
  useEffect(() => {
    if (status === 'ready' && pendingDraft !== null) {
      input.set(pendingDraft);
      setPendingDraft(null);
    }
  }, [status, pendingDraft, input]);

  if (status === 'expired') {
    return (
      <div role="alert">
        <p>Your conversation timed out. Start a new one?</p>
        <button onClick={() => reset()}>New conversation</button>
      </div>
    );
  }

  return <ChatSurface input={input} send={send} />;
}

What the SDK does for you:

  • Transitions to status 'expired' when the session TTL elapses
  • Preserves session-scoped storage until governance retention rules clean it
  • Emits SESSION_EXPIRED as a terminal error (not retryable)

What you must do:

  • Detect the expired state and offer a "New conversation" affordance
  • Preserve the user's in-progress input across the reset boundary
  • If you're tracking conversation history (see Identity and Continuity), the expired session remains in the registry for resumption

6. Upload errors

Upload errors are inherently per-attachment. Show the error inline next to the offending file and offer a retry that exercises retryAttachment.

function AttachmentList() {
  const { messages, retryAttachment, removeAttachment } = useChatSession({ /* ... */ });

  return (
    <ul>
      {extractAttachments(messages).map((attachment) => {
        if (attachment.status === 'failed') {
          return (
            <li key={attachment.id}>
              <span>{attachment.name}</span>
              <ErrorMessage code={attachment.error?.code} />
              <button onClick={() => retryAttachment(attachment.id)}>Retry</button>
              <button onClick={() => removeAttachment(attachment.id)}>Remove</button>
            </li>
          );
        }
        return <li key={attachment.id}>{attachment.name} — {attachment.status}</li>;
      })}
    </ul>
  );
}

function ErrorMessage({ code }: { code?: string }) {
  switch (code) {
    case 'FILE_VALIDATION_FAILED':
      return <span>This file type isn't supported. Try PDF, PNG, or JPG.</span>;
    case 'UPLOAD_FAILED':
      return <span>Upload didn't complete — check your connection.</span>;
    default:
      return null;
  }
}

What the SDK does for you:

  • Validates files against acceptedTypes and maxSizeBytes before upload
  • Tracks per-attachment lifecycle (pendinguploadingcomplete/failed)
  • Exposes retryAttachment(id) to redo a failed upload without re-selecting the file

What you must do:

  • Show the failed status inline next to the attachment
  • Offer Retry and Remove affordances
  • Validate user expectations against your acceptedTypes policy in the file picker UX

See also

Source: docs/guides/error-recovery.md