Error Handling
Error Handling
Handle API errors from the Go SDK using the *ApiError type, classification helpers, and recovery patterns.
The ApiError Type
The SDK wraps every non-2xx HTTP response in an *ApiError:
type ApiError struct {
StatusCode int // HTTP status code (e.g., 404, 401, 500)
Message string // Server-provided error message from JSON response
Body string // Raw response body for debugging
}
The Error() method returns a formatted string:
modula: 404 content not found
modula: 401 Unauthorized
The SDK extracts Message from the JSON response body's "message" or "error" field. If the response is not JSON or lacks these fields, Message is empty and Error() falls back to the standard HTTP status text.
Extracting ApiError
Use errors.As to extract the concrete error type:
item, err := client.ContentData.Get(ctx, contentID)
if err != nil {
var apiErr *modula.ApiError
if errors.As(err, &apiErr) {
fmt.Printf("HTTP %d: %s\n", apiErr.StatusCode, apiErr.Message)
fmt.Printf("Raw body: %s\n", apiErr.Body)
}
return err
}
The SDK wraps errors with additional context (e.g., "query blog-posts: modula: 404 Not Found"), so always use errors.As rather than type assertion.
Classification Helpers
The SDK provides four convenience functions that check the error type and status code. These work on wrapped errors.
IsNotFound
func IsNotFound(err error) bool
Returns true if the error is an *ApiError with HTTP status 404. Use after Get or Delete calls:
item, err := client.ContentData.Get(ctx, id)
if modula.IsNotFound(err) {
// The content item does not exist.
return nil
}
if err != nil {
return fmt.Errorf("fetch content: %w", err)
}
IsUnauthorized
func IsUnauthorized(err error) bool
Returns true if the error is an *ApiError with HTTP status 401. Indicates a missing, expired, or invalid API key or session:
me, err := client.Auth.Me(ctx)
if modula.IsUnauthorized(err) {
// Token expired or missing. Re-authenticate.
return redirectToLogin()
}
IsDuplicateMedia
func IsDuplicateMedia(err error) bool
Returns true if the error is an *ApiError with HTTP status 409 (Conflict). The server returns this when a media upload duplicates an existing file:
media, err := client.MediaUpload.Upload(ctx, file, "photo.jpg", nil)
if modula.IsDuplicateMedia(err) {
// File already exists. Fetch the existing one instead.
fmt.Println("Duplicate upload skipped")
return nil
}
IsInvalidMediaPath
func IsInvalidMediaPath(err error) bool
Returns true if the error is an *ApiError with HTTP status 400 whose body mentions path traversal or invalid path characters. The server returns this when a media upload path contains .. segments or disallowed characters:
media, err := client.MediaUpload.Upload(ctx, file, "photo.jpg", &modula.MediaUploadOptions{
Path: userProvidedPath,
})
if modula.IsInvalidMediaPath(err) {
return fmt.Errorf("invalid upload path: %s", userProvidedPath)
}
Error Handling Patterns
Pattern: Classify Then Fall Through
Handle the most common cases explicitly and let everything else propagate:
func getContent(ctx context.Context, client *modula.Client, id modula.ContentID) (*modula.ContentData, error) {
item, err := client.ContentData.Get(ctx, id)
if modula.IsNotFound(err) {
return nil, nil // Caller checks for nil
}
if modula.IsUnauthorized(err) {
return nil, fmt.Errorf("authentication required: %w", err)
}
if err != nil {
return nil, fmt.Errorf("get content %s: %w", id, err)
}
return item, nil
}
Pattern: Retry on Transient Errors
Server errors (5xx) are often transient. Retry with backoff:
func getWithRetry(ctx context.Context, client *modula.Client, id modula.ContentID) (*modula.ContentData, error) {
var lastErr error
for attempt := range 3 {
item, err := client.ContentData.Get(ctx, id)
if err == nil {
return item, nil
}
var apiErr *modula.ApiError
if errors.As(err, &apiErr) && apiErr.StatusCode < 500 {
return nil, err // Client errors are not retryable.
}
lastErr = err
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(attempt+1) * time.Second):
}
}
return nil, fmt.Errorf("exhausted retries: %w", lastErr)
}
Pattern: Context Cancellation
All SDK methods accept a context.Context. A cancelled context returns context.Canceled; a deadline-exceeded context returns context.DeadlineExceeded. These are not *ApiError values:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
items, err := client.ContentData.List(ctx)
if errors.Is(err, context.DeadlineExceeded) {
log.Println("Request timed out")
}
Pattern: Logging with Full Context
Use the Body field for detailed diagnostics:
item, err := client.ContentData.Get(ctx, id)
if err != nil {
var apiErr *modula.ApiError
if errors.As(err, &apiErr) {
slog.Error("API call failed",
"status", apiErr.StatusCode,
"message", apiErr.Message,
"body", apiErr.Body,
"content_id", id,
)
}
return err
}
Next Steps
- Getting Started -- client setup and authentication
- Content Operations -- CRUD, trees, publishing
- Pagination -- paginated listings