API Error Handling

7 minute read

Error Handling Overview

Good error handling makes the difference between a frustrating and delightful user experience. Your provider should translate API errors into clear, actionable messages.

HTTP Status Codes

Common Status Codes

func (c *Client) handleHTTPError(resp *http.Response, body []byte) error {
    switch resp.StatusCode {
    case 200, 201, 202, 204:
        return nil  // Success
        
    case 400:
        return c.parseBadRequestError(body)
        
    case 401:
        return &AuthenticationError{
            Message: "Invalid API key or token",
        }
        
    case 403:
        return &AuthorizationError{
            Message: "Insufficient permissions for this operation",
        }
        
    case 404:
        return ErrNotFound
        
    case 409:
        return c.parseConflictError(body)
        
    case 422:
        return c.parseValidationError(body)
        
    case 429:
        return c.parseRateLimitError(resp, body)
        
    case 500, 502, 503, 504:
        return &ServerError{
            StatusCode: resp.StatusCode,
            Message:    fmt.Sprintf("API server error: %d", resp.StatusCode),
            Retryable:  true,
        }
        
    default:
        return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
    }
}

Custom Error Types

Base Error Type

package client

type APIError struct {
    StatusCode int                    `json:"status_code"`
    Code       string                 `json:"code"`
    Message    string                 `json:"message"`
    Details    map[string]interface{} `json:"details,omitempty"`
    Retryable  bool                   `json:"-"`
}

func (e *APIError) Error() string {
    if e.Code != "" {
        return fmt.Sprintf("[%s] %s", e.Code, e.Message)
    }
    return e.Message
}

func (e *APIError) IsRetryable() bool {
    return e.Retryable
}

Specific Error Types

// Not found error
var ErrNotFound = &APIError{
    StatusCode: 404,
    Code:       "NOT_FOUND",
    Message:    "Resource not found",
    Retryable:  false,
}

type NotFoundError struct {
    ResourceType string
    ResourceID   string
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s with ID %s not found", e.ResourceType, e.ResourceID)
}

func IsNotFoundError(err error) bool {
    if err == nil {
        return false
    }
    
    if err == ErrNotFound {
        return true
    }
    
    var notFoundErr *NotFoundError
    return errors.As(err, &notFoundErr)
}

// Authentication error
type AuthenticationError struct {
    Message string
}

func (e *AuthenticationError) Error() string {
    return fmt.Sprintf("authentication failed: %s", e.Message)
}

// Authorization error
type AuthorizationError struct {
    Message    string
    Required   []string  // Required permissions
}

func (e *AuthorizationError) Error() string {
    if len(e.Required) > 0 {
        return fmt.Sprintf("authorization failed: %s (requires: %v)", e.Message, e.Required)
    }
    return fmt.Sprintf("authorization failed: %s", e.Message)
}

// Validation error
type ValidationError struct {
    Field   string
    Message string
    Errors  []FieldError
}

type FieldError struct {
    Field   string `json:"field"`
    Message string `json:"message"`
}

func (e *ValidationError) Error() string {
    if len(e.Errors) > 0 {
        var messages []string
        for _, fe := range e.Errors {
            messages = append(messages, fmt.Sprintf("%s: %s", fe.Field, fe.Message))
        }
        return "validation failed: " + strings.Join(messages, "; ")
    }
    return fmt.Sprintf("validation failed: %s", e.Message)
}

// Rate limit error
type RateLimitError struct {
    Limit     int
    Remaining int
    ResetAt   time.Time
}

func (e *RateLimitError) Error() string {
    return fmt.Sprintf("rate limit exceeded (limit: %d, resets at: %s)", 
        e.Limit, e.ResetAt.Format(time.RFC3339))
}

// Server error
type ServerError struct {
    StatusCode int
    Message    string
    Retryable  bool
}

func (e *ServerError) Error() string {
    return fmt.Sprintf("server error (%d): %s", e.StatusCode, e.Message)
}

func (e *ServerError) IsRetryable() bool {
    return e.Retryable
}

// Conflict error
type ConflictError struct {
    Message      string
    ConflictWith string
}

func (e *ConflictError) Error() string {
    if e.ConflictWith != "" {
        return fmt.Sprintf("conflict: %s (conflicts with: %s)", e.Message, e.ConflictWith)
    }
    return fmt.Sprintf("conflict: %s", e.Message)
}

Parse API Errors

Parse JSON Error Response

func (c *Client) parseAPIError(resp *http.Response) error {
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return fmt.Errorf("failed to read error response: %w", err)
    }
    
    var apiError APIError
    if err := json.Unmarshal(body, &apiError); err != nil {
        // Couldn't parse JSON, return raw body
        return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body))
    }
    
    apiError.StatusCode = resp.StatusCode
    return &apiError
}

Parse Validation Errors

func (c *Client) parseValidationError(body []byte) error {
    var response struct {
        Message string       `json:"message"`
        Errors  []FieldError `json:"errors"`
    }
    
    if err := json.Unmarshal(body, &response); err != nil {
        return fmt.Errorf("validation failed: %s", string(body))
    }
    
    return &ValidationError{
        Message: response.Message,
        Errors:  response.Errors,
    }
}

Parse Rate Limit Errors

func (c *Client) parseRateLimitError(resp *http.Response, body []byte) error {
    limit, _ := strconv.Atoi(resp.Header.Get("X-RateLimit-Limit"))
    remaining, _ := strconv.Atoi(resp.Header.Get("X-RateLimit-Remaining"))
    resetTimestamp, _ := strconv.ParseInt(resp.Header.Get("X-RateLimit-Reset"), 10, 64)
    
    return &RateLimitError{
        Limit:     limit,
        Remaining: remaining,
        ResetAt:   time.Unix(resetTimestamp, 0),
    }
}

Error Checking Helpers

Type Assertions

func IsNotFoundError(err error) bool {
    if err == nil {
        return false
    }
    
    var notFoundErr *NotFoundError
    return errors.As(err, &notFoundErr) || err == ErrNotFound
}

func IsAuthenticationError(err error) bool {
    if err == nil {
        return false
    }
    
    var authErr *AuthenticationError
    return errors.As(err, &authErr)
}

func IsValidationError(err error) bool {
    if err == nil {
        return false
    }
    
    var valErr *ValidationError
    return errors.As(err, &valErr)
}

func IsRateLimitError(err error) bool {
    if err == nil {
        return false
    }
    
    var rateLimitErr *RateLimitError
    return errors.As(err, &rateLimitErr)
}

func IsRetryableError(err error) bool {
    if err == nil {
        return false
    }
    
    // Check if error implements Retryable interface
    type retryable interface {
        IsRetryable() bool
    }
    
    if r, ok := err.(retryable); ok {
        return r.IsRetryable()
    }
    
    // Server errors are retryable
    var serverErr *ServerError
    if errors.As(err, &serverErr) {
        return serverErr.Retryable
    }
    
    // Network errors are retryable
    if errors.Is(err, context.DeadlineExceeded) {
        return true
    }
    
    return false
}

Resource Error Handling

Handle Errors in Resources

func (r *DatabaseResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
    var plan DatabaseModel
    resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
    
    input := client.CreateDatabaseInput{
        Name:      plan.Name.ValueString(),
        Engine:    plan.Engine.ValueString(),
        StorageGB: int(plan.StorageGB.ValueInt64()),
    }
    
    db, err := r.client.CreateDatabase(ctx, input)
    if err != nil {
        // Handle specific error types
        if IsValidationError(err) {
            resp.Diagnostics.AddError(
                "Invalid Configuration",
                fmt.Sprintf("Database configuration is invalid: %s", err.Error()),
            )
            return
        }
        
        if IsAuthenticationError(err) {
            resp.Diagnostics.AddError(
                "Authentication Failed",
                "API key is invalid or expired. Please update your provider configuration.",
            )
            return
        }
        
        if IsAuthorizationError(err) {
            resp.Diagnostics.AddError(
                "Permission Denied",
                fmt.Sprintf("Insufficient permissions to create database: %s", err.Error()),
            )
            return
        }
        
        // Generic error
        resp.Diagnostics.AddError(
            "Failed to Create Database",
            fmt.Sprintf("Unable to create database: %s", err.Error()),
        )
        return
    }
    
    // Success - update state
    plan.ID = types.StringValue(db.ID)
    resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}

Handle Not Found in Read

func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state DatabaseModel
    resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
    
    db, err := r.client.GetDatabase(ctx, state.ID.ValueString())
    if err != nil {
        // Resource deleted outside Terraform
        if IsNotFoundError(err) {
            resp.State.RemoveResource(ctx)
            return
        }
        
        // Other errors
        resp.Diagnostics.AddError(
            "Failed to Read Database",
            fmt.Sprintf("Unable to read database %s: %s", state.ID.ValueString(), err.Error()),
        )
        return
    }
    
    // Update state
    state.Name = types.StringValue(db.Name)
    resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}

Error Messages

Good Error Messages

// ✅ Good: Specific and actionable
resp.Diagnostics.AddError(
    "Database Name Already Exists",
    fmt.Sprintf("A database named '%s' already exists in region '%s'. "+
        "Choose a different name or delete the existing database.",
        plan.Name.ValueString(), plan.Region.ValueString()),
)

// ✅ Good: Include troubleshooting hints
resp.Diagnostics.AddError(
    "API Connection Failed",
    fmt.Sprintf("Unable to connect to API at %s. "+
        "Check that:\n"+
        "  • The endpoint URL is correct\n"+
        "  • Your network allows outbound HTTPS connections\n"+
        "  • The API service is available\n\n"+
        "Error: %s", endpoint, err.Error()),
)

// ✅ Good: Suggest next steps
resp.Diagnostics.AddError(
    "Invalid API Key",
    "The provided API key is invalid or expired. "+
        "Generate a new API key at https://console.example.com/api-keys "+
        "and update your provider configuration.",
)

Bad Error Messages

// ❌ Bad: Too generic
resp.Diagnostics.AddError("Error", err.Error())

// ❌ Bad: No context
resp.Diagnostics.AddError("Failed", "Operation failed")

// ❌ Bad: Technical jargon
resp.Diagnostics.AddError(
    "HTTP 422",
    "Unprocessable entity error occurred",
)

Logging Errors

Structured Logging

import "github.com/hashicorp/terraform-plugin-log/tflog"

func (c *Client) GetDatabase(ctx context.Context, id string) (*Database, error) {
    tflog.Debug(ctx, "Getting database", map[string]interface{}{
        "database_id": id,
    })
    
    resp, err := c.get(ctx, fmt.Sprintf("/databases/%s", id), nil)
    if err != nil {
        tflog.Error(ctx, "Failed to get database", map[string]interface{}{
            "database_id": id,
            "error":       err.Error(),
        })
        return nil, err
    }
    
    var db Database
    if err := json.NewDecoder(resp.Body).Decode(&db); err != nil {
        tflog.Error(ctx, "Failed to decode database response", map[string]interface{}{
            "database_id": id,
            "error":       err.Error(),
        })
        return nil, err
    }
    
    tflog.Info(ctx, "Successfully retrieved database", map[string]interface{}{
        "database_id":   db.ID,
        "database_name": db.Name,
    })
    
    return &db, nil
}

Best Practices

1. Create Custom Error Types

// ✅ Good: Custom types for type checking
type NotFoundError struct {
    ResourceType string
    ResourceID   string
}

func IsNotFoundError(err error) bool {
    var notFoundErr *NotFoundError
    return errors.As(err, &notFoundErr)
}

// ❌ Bad: String comparison
if strings.Contains(err.Error(), "not found") {
    // Fragile and unreliable
}

2. Wrap Errors with Context

// ✅ Good: Add context
db, err := c.GetDatabase(ctx, id)
if err != nil {
    return nil, fmt.Errorf("failed to get database %s: %w", id, err)
}

// ❌ Bad: Lose context
db, err := c.GetDatabase(ctx, id)
if err != nil {
    return nil, err
}

3. Check Specific Errors First

// ✅ Good: Check specific errors
if IsNotFoundError(err) {
    resp.State.RemoveResource(ctx)
    return
}

if IsValidationError(err) {
    resp.Diagnostics.AddError("Invalid Configuration", err.Error())
    return
}

// Generic fallback
resp.Diagnostics.AddError("Error", err.Error())

// ❌ Bad: Only generic handling
resp.Diagnostics.AddError("Error", err.Error())

Testing Error Handling

Test Error Scenarios

func TestClient_GetDatabase_NotFound(t *testing.T) {
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusNotFound)
        json.NewEncoder(w).Encode(map[string]string{
            "code":    "NOT_FOUND",
            "message": "Database not found",
        })
    }))
    defer server.Close()
    
    client, _ := NewClient(&ClientConfig{
        APIKey:  "test-key",
        BaseURL: server.URL,
    })
    
    _, err := client.GetDatabase(context.Background(), "db-123")
    
    assert.Error(t, err)
    assert.True(t, IsNotFoundError(err))
}

func TestClient_GetDatabase_RateLimit(t *testing.T) {
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("X-RateLimit-Limit", "100")
        w.Header().Set("X-RateLimit-Remaining", "0")
        w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", time.Now().Add(1*time.Hour).Unix()))
        w.WriteHeader(http.StatusTooManyRequests)
    }))
    defer server.Close()
    
    client, _ := NewClient(&ClientConfig{
        APIKey:  "test-key",
        BaseURL: server.URL,
    })
    
    _, err := client.GetDatabase(context.Background(), "db-123")
    
    assert.Error(t, err)
    assert.True(t, IsRateLimitError(err))
    
    var rateLimitErr *RateLimitError
    if errors.As(err, &rateLimitErr) {
        assert.Equal(t, 100, rateLimitErr.Limit)
        assert.Equal(t, 0, rateLimitErr.Remaining)
    }
}

Key Takeaways

  • ✅ Create custom error types for different scenarios
  • ✅ Parse API errors into structured types
  • ✅ Provide clear, actionable error messages
  • ✅ Check specific errors before generic ones
  • ✅ Handle NotFound by removing from state
  • ✅ Log errors with structured logging
  • ✅ Wrap errors with context
  • ✅ Test error handling paths
  • ✅ Mark retryable errors
  • ✅ Include troubleshooting hints in messages

What’s Next?

Learn how to implement rate limiting and retry logic with exponential backoff.

Next: Rate Limiting & Retries →


Previous: ← Authentication Patterns

Last updated: July 2, 2026

Updated: