Skip to main content

Error Handling

All GospeLib services use a shared error catalog and a consistent JSON response envelope. This guide covers how to use them.

Response Envelope

Every API response follows this shape:

// Success
{
"data": { ... },
"meta": { "next_cursor": "abc123", "total": 42 }
}

// Error
{
"error": {
"code": "PASSAGE_NOT_FOUND",
"message": "Passage not found",
"request_id": "req_abc123"
}
}
  • data wraps the response payload on success
  • meta contains pagination info (next_cursor) when applicable
  • error.code is an UPPER_SNAKE_CASE machine-readable code from the error catalog
  • error.request_id is the X-Request-ID header injected by the gateway

Error Catalog

The single source of truth for all error codes lives in data/error-catalog.yaml:

CodeHTTP StatusMessage
PASSAGE_NOT_FOUND404Passage not found
CHAPTER_NOT_FOUND404Chapter not found
BOOK_NOT_FOUND404Book not found
TOPIC_NOT_FOUND404Topic not found
LEXICON_ENTRY_NOT_FOUND404Lexicon entry not found
INVALID_REQUEST400Invalid request
VALIDATION_ERROR422Validation error
UNAUTHORIZED401Unauthorized
FORBIDDEN403Forbidden
RATE_LIMITED429Rate limit exceeded
INTERNAL_ERROR500Internal server error
SERVICE_UNAVAILABLE503Service unavailable
UPSTREAM_UNAVAILABLE502The upstream service is temporarily unavailable
NOT_IMPLEMENTED501Not implemented

Run pnpm codegen:errors to regenerate per-language error files from this YAML.

Go Error Patterns

Sentinel errors

Each error code generates a Go sentinel variable (e.g., ErrPassageNotFound):

var ErrPassageNotFound = &AppError{Code: "PASSAGE_NOT_FOUND", Status: 404, Message: "Passage not found"}

Return errors, don't panic

Always return error as the last value. Never panic for recoverable errors:

func (s *Service) GetPassage(ctx context.Context, id string) (*Passage, error) {
passage, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("GetPassage(%s): %w", id, err)
}
if passage == nil {
return nil, ErrPassageNotFound
}
return passage, nil
}

Wrap errors with context

Use fmt.Errorf with %w to preserve the error chain:

if err := s.repo.Save(ctx, user); err != nil {
return fmt.Errorf("creating user %s: %w", user.ID, err)
}

Write JSON error responses in handlers

func (h *Handler) GetPassage(w http.ResponseWriter, r *http.Request) {
passage, err := h.service.GetPassage(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeError(w, r, err) // maps AppError → JSON envelope
return
}
writeJSON(w, r, http.StatusOK, envelope{Data: passage})
}

Python Error Patterns

Domain exceptions

Raise domain exceptions that map to HTTP status codes:

class PassageNotFoundError(Exception):
status_code = 404
code = "PASSAGE_NOT_FOUND"
message = "Passage not found"

FastAPI exception handlers

Register a global handler that converts domain exceptions to the error envelope:

@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError):
return JSONResponse(
status_code=exc.status_code,
content={
"error": {
"code": exc.code,
"message": exc.message,
"request_id": request.headers.get("x-request-id", ""),
}
},
)

Use response_model for type safety

Never return bare dict from route handlers — always use Pydantic models with response_model:

@router.get("/{passage_id}", response_model=PassageResponse)
async def get_passage(passage_id: str, graph: GraphClient = Depends(get_graph_client)):
rows = await graph.query(GET_PASSAGE, {"passage_id": passage_id})
if not rows:
raise PassageNotFoundError()
return PassageResponse.from_graph(rows)

Pydantic extra="forbid"

All Pydantic models use extra="forbid" to catch unexpected fields at validation time:

class PassageResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
book_id: str
chapter: int
verse: int
text: str

TypeScript Error Patterns

SDK error handling

The @gospelib/sdk package returns typed errors that match the envelope:

import { client } from '@gospelib/sdk';

const { data, error } = await client.GET('/api/v1/passages/{passage_id}', {
params: { path: { passage_id: 'gen.1.1' } },
});

if (error) {
// error.code is typed: 'PASSAGE_NOT_FOUND' | 'UNAUTHORIZED' | ...
console.error(error.code, error.message);
}

React error boundaries

Use typed error boundaries in React components for graceful degradation:

<ErrorBoundary fallback={<PassageErrorFallback />}>
<PassageContent id={passageId} />
</ErrorBoundary>

Adding a New Error Code

  1. Add the entry to data/error-catalog.yaml:

    - code: COMMENTARY_NOT_FOUND
    status: 404
    message: 'Commentary not found'
  2. Run code generation:

    pnpm codegen:errors
  3. Use the generated constant in your service code