Skip to content

Error Handling

Robust error handling is the difference between an application that fails gracefully and one that leaves users staring at a blank screen. This chapter covers error handling on both sides of the stack — mapping Gaia API errors to your backend's HTTP responses, and catching those errors in the React frontend.


Gaia API Error Responses

When Gaia returns an error, the response body typically follows this shape:

JSON
{
  "message": "Dataset 'nonexistent' not found.",
  "errorCode": "kNotFound"
}

Or in some cases:

JSON
{
  "errorMessage": "Unauthorized: invalid API key."
}

The HTTP status codes you'll encounter most often:

Status Meaning Common Cause
401 Unauthorized Invalid or expired API key
403 Forbidden Key lacks permission for this operation
404 Not Found Dataset or resource doesn't exist
429 Too Many Requests Rate limit exceeded
500 Internal Server Error Gaia-side failure
502/503 Bad Gateway / Unavailable Gaia cluster is down or unreachable

Backend Error Handling

The SDK Exception Hierarchy

The gaia_sdk maps these status codes to typed exceptions:

Text Only
GaiaError (base)
├── GaiaAuthError          (401, 403)
├── GaiaNotFoundError      (404)
├── GaiaRateLimitError     (429)
├── GaiaServerError        (5xx)
└── GaiaTimeoutError       (timeout)

Every exception carries status_code and response_body attributes for inspection.

Mapping Gaia Errors to Your HTTP Responses

Create a centralized error mapping module that translates SDK exceptions into FastAPI HTTPException responses with a consistent error envelope.

utils/errors.py

Python
"""Error mapping utilities."""

from fastapi import HTTPException, status

from gaia_sdk.exceptions import (
    GaiaAuthError,
    GaiaError,
    GaiaNotFoundError,
    GaiaRateLimitError,
    GaiaServerError,
    GaiaTimeoutError,
)


def error_envelope(code: str, message: str) -> dict:
    """Build a standard error response body."""
    return {"error": {"code": code, "message": message}}


def raise_http_error(exc: GaiaError) -> None:
    """Map a GaiaError to the appropriate HTTPException and raise it."""

    if isinstance(exc, GaiaAuthError):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=error_envelope("unauthorized", str(exc)),
        )

    if isinstance(exc, GaiaNotFoundError):
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=error_envelope("not_found", str(exc)),
        )

    if isinstance(exc, GaiaRateLimitError):
        raise HTTPException(
            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
            detail=error_envelope("rate_limited", str(exc)),
            headers={"Retry-After": "30"},
        )

    if isinstance(exc, GaiaTimeoutError):
        raise HTTPException(
            status_code=status.HTTP_504_GATEWAY_TIMEOUT,
            detail=error_envelope("timeout", "The request to Gaia timed out."),
        )

    if isinstance(exc, GaiaServerError):
        raise HTTPException(
            status_code=status.HTTP_502_BAD_GATEWAY,
            detail=error_envelope("upstream_error", "Gaia returned a server error."),
        )

    # Generic GaiaError fallback
    raise HTTPException(
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        detail=error_envelope("internal_error", str(exc)),
    )

Using raise_http_error in Routes

Python
from backend.utils.errors import raise_http_error
from gaia_sdk.exceptions import GaiaError


@router.post("/ask", response_model=AskResponse, tags=["Ask"])
async def ask(
    request: AskRequest,
    client: GaiaClient = Depends(get_gaia_client),
):
    try:
        result = await client.ask(
            dataset_names=request.dataset_names,
            query=request.query,
            conversation_id=request.conversation_id,
        )
    except GaiaError as exc:
        raise_http_error(exc)

    return AskResponse(
        response=result.response_string,
        query_uid=result.query_uid,
        conversation_id=result.conversation_id,
        documents=result.documents,
    )

Global Exception Handler

For exceptions that slip through route-level handling, register a global exception handler:

Python
# In main.py, inside create_app()

from fastapi import Request
from fastapi.responses import JSONResponse
from gaia_sdk.exceptions import GaiaError


@app.exception_handler(GaiaError)
async def gaia_error_handler(request: Request, exc: GaiaError):
    """Catch any unhandled GaiaError and return a structured response."""
    status_map = {
        401: 401, 403: 401,
        404: 404,
        429: 429,
    }
    http_status = status_map.get(exc.status_code or 0, 502)
    return JSONResponse(
        status_code=http_status,
        content={"error": {"code": "gaia_error", "message": str(exc)}},
    )


@app.exception_handler(Exception)
async def generic_error_handler(request: Request, exc: Exception):
    """Catch-all for unexpected errors."""
    return JSONResponse(
        status_code=500,
        content={"error": {"code": "internal_error", "message": "An unexpected error occurred."}},
    )

Don't leak internal details

The generic handler should never include stack traces or internal state in the response. Log the full exception server-side and return a safe message to the caller.


Consistent Error Envelope

All error responses from your backend should follow the same shape so the frontend can parse them uniformly:

JSON
{
  "error": {
    "code": "not_found",
    "message": "Dataset 'nonexistent' not found."
  }
}

This structure is produced by the error_envelope() helper and used by both raise_http_error() and the global exception handlers.


Frontend Error Handling

The ApiError Class

The API client (api/client.ts) already throws ApiError for non-2xx responses:

TypeScript
export class ApiError extends Error {
  constructor(
    public status: number,
    public detail: string,
  ) {
    super(detail);
    this.name = "ApiError";
  }
}

try/catch in API Calls

TypeScript
import { ApiError } from "../api/client";
import { ask } from "../api/gaia";

try {
  const response = await ask(["my-dataset"], "What is our revenue?");
  // handle success
} catch (error) {
  if (error instanceof ApiError) {
    switch (error.status) {
      case 401:
        // Session expired — redirect to login
        window.location.href = "/login";
        break;
      case 404:
        showToast("Dataset not found.", "error");
        break;
      case 429:
        showToast("Too many requests. Please wait.", "warning");
        break;
      default:
        showToast(error.detail || "Something went wrong.", "error");
    }
  } else {
    showToast("Network error. Check your connection.", "error");
  }
}

React Error Boundaries

Error boundaries catch rendering errors that would otherwise crash the entire app.

TSX
// src/components/ErrorBoundary.tsx

import { Component, type ReactNode } from "react";

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}

interface State {
  hasError: boolean;
  error: Error | null;
}

export class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false, error: null };

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    console.error("ErrorBoundary caught:", error, info);
  }

  render() {
    if (this.state.hasError) {
      return (
        this.props.fallback ?? (
          <div className="p-8 text-center">
            <h2 className="text-xl font-bold text-red-600 mb-2">
              Something went wrong
            </h2>
            <p className="text-gray-600">
              {this.state.error?.message ?? "An unexpected error occurred."}
            </p>
            <button
              onClick={() => this.setState({ hasError: false, error: null })}
              className="mt-4 rounded bg-purple-600 px-4 py-2 text-white"
            >
              Try Again
            </button>
          </div>
        )
      );
    }
    return this.props.children;
  }
}

Wrap your routes with the boundary:

TSX
<ErrorBoundary>
  <Routes>
    <Route path="/chat" element={<ChatPage />} />
  </Routes>
</ErrorBoundary>

Toast Notifications

For non-fatal errors, use toast notifications. Here's a minimal implementation:

TypeScript
// src/hooks/useToast.ts

import { create } from "zustand";

interface Toast {
  id: string;
  message: string;
  type: "info" | "error" | "warning" | "success";
}

interface ToastState {
  toasts: Toast[];
  addToast: (message: string, type: Toast["type"]) => void;
  removeToast: (id: string) => void;
}

export const useToastStore = create<ToastState>((set) => ({
  toasts: [],

  addToast: (message, type) => {
    const id = `toast-${Date.now()}`;
    set((state) => ({
      toasts: [...state.toasts, { id, message, type }],
    }));
    setTimeout(() => {
      set((state) => ({
        toasts: state.toasts.filter((t) => t.id !== id),
      }));
    }, 5000);
  },

  removeToast: (id) =>
    set((state) => ({
      toasts: state.toasts.filter((t) => t.id !== id),
    })),
}));

export function showToast(message: string, type: Toast["type"] = "info") {
  useToastStore.getState().addToast(message, type);
}

Retry Strategies for Transient Errors

Some errors are transient — the Gaia cluster might be temporarily overloaded, or a network hiccup might cause a timeout. Implement retries with exponential backoff for these cases.

Backend: Retry with httpx

Python
"""Retry helper for transient Gaia errors."""

import asyncio
import logging

from gaia_sdk.exceptions import GaiaRateLimitError, GaiaServerError, GaiaTimeoutError

logger = logging.getLogger(__name__)

TRANSIENT_EXCEPTIONS = (GaiaRateLimitError, GaiaServerError, GaiaTimeoutError)


async def with_retry(
    func,
    *args,
    max_retries: int = 3,
    base_delay: float = 1.0,
    max_delay: float = 30.0,
    **kwargs,
):
    """Call an async function with exponential backoff on transient errors."""
    last_exception = None

    for attempt in range(max_retries + 1):
        try:
            return await func(*args, **kwargs)
        except TRANSIENT_EXCEPTIONS as exc:
            last_exception = exc
            if attempt == max_retries:
                break
            delay = min(base_delay * (2 ** attempt), max_delay)
            logger.warning(
                "Attempt %d/%d failed (%s), retrying in %.1fs",
                attempt + 1, max_retries + 1, type(exc).__name__, delay,
            )
            await asyncio.sleep(delay)

    raise last_exception

Usage:

Python
result = await with_retry(
    client.ask,
    dataset_names=["my-dataset"],
    query="What happened yesterday?",
    max_retries=2,
)

Frontend: Retry with fetch

TypeScript
async function fetchWithRetry(
  url: string,
  options: RequestInit,
  maxRetries = 3,
): Promise<Response> {
  let lastError: Error | null = null;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);
      if (response.status === 429 || response.status >= 500) {
        throw new Error(`HTTP ${response.status}`);
      }
      return response;
    } catch (error: any) {
      lastError = error;
      if (attempt < maxRetries) {
        const delay = Math.min(1000 * 2 ** attempt, 30000);
        await new Promise((r) => setTimeout(r, delay));
      }
    }
  }

  throw lastError;
}

Don't retry everything

Only retry on transient errors: timeouts (504), rate limits (429), and server errors (5xx). Never retry on auth errors (401/403) or validation errors (400/422) — those won't succeed on retry.


Summary

Layer Strategy Implementation
SDK Typed exception hierarchy GaiaAuthError, GaiaNotFoundError, etc.
Backend routes raise_http_error() pattern Maps SDK exceptions to HTTPException
Backend global Exception handler middleware Catches unhandled GaiaError and generic Exception
Frontend API ApiError class Status-aware error with detail
Frontend UI Error boundaries + toasts Crash recovery + non-fatal notifications
Both Retry with backoff Transient errors only

Next Steps