API Client Lab

10 minute read

Lab Overview

Build a complete API client for the CacheCloud API with all production features:

  • ✅ Configurable HTTP client
  • ✅ API key authentication
  • ✅ Custom error types
  • ✅ Exponential backoff retries
  • ✅ Rate limiting
  • ✅ Structured logging

Time: 60-90 minutes

Prerequisites

  • Completed previous modules
  • Go 1.21+ installed
  • Text editor or IDE

Lab Setup

Create Client Package

mkdir -p internal/client
cd internal/client

Project Structure

internal/client/
├── client.go           # Main client
├── config.go           # Configuration
├── errors.go           # Custom errors
├── http.go             # HTTP helpers
├── cache.go            # Cache operations
├── client_test.go      # Tests
└── README.md

Step 1: Client Configuration

Create config.go

package client

import (
    "fmt"
    "time"
)

type Config struct {
    APIKey    string
    BaseURL   string
    UserAgent string
    Timeout   time.Duration
    
    // Retry configuration
    MaxRetries int
    MinWait    time.Duration
    MaxWait    time.Duration
    
    // Rate limiting
    RateLimit float64  // requests per second
    Burst     int
}

func DefaultConfig() *Config {
    return &Config{
        BaseURL:    "https://api.cachecloud.example.com",
        UserAgent:  "terraform-provider-cachecloud/1.0.0",
        Timeout:    60 * time.Second,
        MaxRetries: 3,
        MinWait:    1 * time.Second,
        MaxWait:    30 * time.Second,
        RateLimit:  100.0,  // 100 req/s
        Burst:      10,
    }
}

func (c *Config) Validate() error {
    if c.APIKey == "" {
        return fmt.Errorf("API key is required")
    }
    
    if c.BaseURL == "" {
        return fmt.Errorf("base URL is required")
    }
    
    if c.Timeout <= 0 {
        return fmt.Errorf("timeout must be positive")
    }
    
    if c.MaxRetries < 0 {
        return fmt.Errorf("max retries must be non-negative")
    }
    
    return nil
}

Step 2: Custom Error Types

Create errors.go

package client

import (
    "errors"
    "fmt"
    "time"
)

// Base API error
type APIError struct {
    StatusCode int
    Code       string
    Message    string
    Retryable  bool
}

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

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

// Specific error types
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)
}

type AuthenticationError struct {
    Message string
}

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

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)
}

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))
}

func (e *RateLimitError) IsRetryable() bool {
    return true
}

// Error checking helpers
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
    }
    
    type retryable interface {
        IsRetryable() bool
    }
    
    if r, ok := err.(retryable); ok {
        return r.IsRetryable()
    }
    
    return false
}

Step 3: HTTP Client

Create http.go

package client

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "math"
    "math/rand"
    "net/http"
    "strconv"
    "time"
)

func (c *Client) get(ctx context.Context, path string, result interface{}) error {
    return c.doRequest(ctx, "GET", path, nil, result)
}

func (c *Client) post(ctx context.Context, path string, body, result interface{}) error {
    return c.doRequest(ctx, "POST", path, body, result)
}

func (c *Client) put(ctx context.Context, path string, body, result interface{}) error {
    return c.doRequest(ctx, "PUT", path, body, result)
}

func (c *Client) patch(ctx context.Context, path string, body, result interface{}) error {
    return c.doRequest(ctx, "PATCH", path, body, result)
}

func (c *Client) delete(ctx context.Context, path string) error {
    return c.doRequest(ctx, "DELETE", path, nil, nil)
}

func (c *Client) doRequest(ctx context.Context, method, path string, body, result interface{}) error {
    // Build request
    req, err := c.buildRequest(ctx, method, path, body)
    if err != nil {
        return err
    }
    
    // Execute with retries
    resp, err := c.doWithRetry(ctx, req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    
    // Handle response
    return c.handleResponse(resp, result)
}

func (c *Client) buildRequest(ctx context.Context, method, path string, body interface{}) (*http.Request, error) {
    url := c.config.BaseURL + path
    
    var bodyReader io.Reader
    if body != nil {
        jsonBody, err := json.Marshal(body)
        if err != nil {
            return nil, fmt.Errorf("failed to marshal request body: %w", err)
        }
        bodyReader = bytes.NewReader(jsonBody)
    }
    
    req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
    if err != nil {
        return nil, err
    }
    
    // Set headers
    req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("User-Agent", c.config.UserAgent)
    
    return req, nil
}

func (c *Client) doWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
    var resp *http.Response
    var err error
    
    for attempt := 0; attempt <= c.config.MaxRetries; attempt++ {
        // Wait for rate limit
        if err := c.rateLimiter.Wait(ctx); err != nil {
            return nil, fmt.Errorf("rate limiter cancelled: %w", err)
        }
        
        // Clone request body for potential retry
        var bodyBytes []byte
        if req.Body != nil && req.GetBody != nil {
            req.Body, _ = req.GetBody()
        }
        
        // Make request
        resp, err = c.httpClient.Do(req)
        
        // Check if retry is needed
        if !c.shouldRetry(resp, err) {
            return resp, err
        }
        
        // Don't retry on last attempt
        if attempt == c.config.MaxRetries {
            break
        }
        
        // Calculate backoff
        wait := c.calculateBackoff(attempt)
        
        tflog.Debug(ctx, "Retrying request", map[string]interface{}{
            "method":      req.Method,
            "url":         req.URL.String(),
            "attempt":     attempt + 1,
            "max_retries": c.config.MaxRetries,
            "wait":        wait.String(),
        })
        
        // Wait with context cancellation support
        select {
        case <-time.After(wait):
            // Continue to retry
        case <-ctx.Done():
            return nil, ctx.Err()
        }
        
        // Restore body for retry
        if bodyBytes != nil {
            req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
        }
    }
    
    return resp, err
}

func (c *Client) shouldRetry(resp *http.Response, err error) bool {
    // Network errors are retryable
    if err != nil {
        return true
    }
    
    // Rate limit errors
    if resp.StatusCode == 429 {
        return true
    }
    
    // Server errors (5xx)
    if resp.StatusCode >= 500 {
        return true
    }
    
    return false
}

func (c *Client) calculateBackoff(attempt int) time.Duration {
    // Exponential backoff: MinWait * 2^attempt
    wait := float64(c.config.MinWait) * math.Pow(2, float64(attempt))
    
    // Cap at MaxWait
    if wait > float64(c.config.MaxWait) {
        wait = float64(c.config.MaxWait)
    }
    
    // Add jitter (random 0-50% of wait time)
    jitter := time.Duration(rand.Int63n(int64(wait) / 2))
    
    return time.Duration(wait) + jitter
}

func (c *Client) handleResponse(resp *http.Response, result interface{}) error {
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return fmt.Errorf("failed to read response: %w", err)
    }
    
    // Check for errors
    if resp.StatusCode >= 400 {
        return c.parseError(resp, body)
    }
    
    // Parse success response
    if result != nil && len(body) > 0 {
        if err := json.Unmarshal(body, result); err != nil {
            return fmt.Errorf("failed to parse response: %w", err)
        }
    }
    
    return nil
}

func (c *Client) parseError(resp *http.Response, body []byte) error {
    switch resp.StatusCode {
    case 401:
        return &AuthenticationError{
            Message: "Invalid API key or token",
        }
        
    case 404:
        return ErrNotFound
        
    case 422:
        return c.parseValidationError(body)
        
    case 429:
        return c.parseRateLimitError(resp)
        
    case 500, 502, 503, 504:
        return &APIError{
            StatusCode: resp.StatusCode,
            Message:    fmt.Sprintf("API server error: %d", resp.StatusCode),
            Retryable:  true,
        }
        
    default:
        // Try to parse generic API error
        var apiErr APIError
        if err := json.Unmarshal(body, &apiErr); err == nil {
            apiErr.StatusCode = resp.StatusCode
            return &apiErr
        }
        
        return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body))
    }
}

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,
    }
}

func (c *Client) parseRateLimitError(resp *http.Response) 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),
    }
}

Step 4: Main Client

Create client.go

package client

import (
    "context"
    "crypto/tls"
    "net"
    "net/http"
    "time"
    
    "golang.org/x/time/rate"
    "github.com/hashicorp/terraform-plugin-log/tflog"
)

type Client struct {
    config      *Config
    httpClient  *http.Client
    rateLimiter *rate.Limiter
}

func NewClient(config *Config) (*Client, error) {
    // Use defaults if not provided
    if config == nil {
        config = DefaultConfig()
    }
    
    // Validate configuration
    if err := config.Validate(); err != nil {
        return nil, err
    }
    
    // Configure TLS
    tlsConfig := &tls.Config{
        MinVersion: tls.VersionTLS12,
    }
    
    // Configure transport
    transport := &http.Transport{
        Proxy: http.ProxyFromEnvironment,
        DialContext: (&net.Dialer{
            Timeout:   10 * time.Second,
            KeepAlive: 30 * time.Second,
        }).DialContext,
        TLSHandshakeTimeout:   10 * time.Second,
        ResponseHeaderTimeout: 20 * time.Second,
        ExpectContinueTimeout: 1 * time.Second,
        MaxIdleConns:          100,
        MaxIdleConnsPerHost:   10,
        MaxConnsPerHost:       50,
        IdleConnTimeout:       90 * time.Second,
        TLSClientConfig:       tlsConfig,
    }
    
    // Create HTTP client
    httpClient := &http.Client{
        Transport: transport,
        Timeout:   config.Timeout,
    }
    
    // Create rate limiter
    limiter := rate.NewLimiter(rate.Limit(config.RateLimit), config.Burst)
    
    client := &Client{
        config:      config,
        httpClient:  httpClient,
        rateLimiter: limiter,
    }
    
    return client, nil
}

// Validate credentials
func (c *Client) ValidateCredentials(ctx context.Context) error {
    type response struct {
        Valid bool `json:"valid"`
    }
    
    var resp response
    err := c.get(ctx, "/v1/auth/validate", &resp)
    if err != nil {
        return err
    }
    
    if !resp.Valid {
        return &AuthenticationError{
            Message: "Invalid credentials",
        }
    }
    
    return nil
}

Step 5: Cache Operations

Create cache.go

package client

import (
    "context"
    "fmt"
)

type Cache struct {
    ID              string            `json:"id"`
    Name            string            `json:"name"`
    Engine          string            `json:"engine"`
    EngineVersion   string            `json:"engine_version"`
    NodeType        string            `json:"node_type"`
    NumNodes        int               `json:"num_nodes"`
    Port            int               `json:"port"`
    Status          string            `json:"status"`
    Endpoint        string            `json:"endpoint"`
    Tags            map[string]string `json:"tags,omitempty"`
}

type CreateCacheInput struct {
    Name          string            `json:"name"`
    Engine        string            `json:"engine"`
    EngineVersion string            `json:"engine_version"`
    NodeType      string            `json:"node_type"`
    NumNodes      int               `json:"num_nodes"`
    Tags          map[string]string `json:"tags,omitempty"`
}

type UpdateCacheInput struct {
    NumNodes *int               `json:"num_nodes,omitempty"`
    Tags     *map[string]string `json:"tags,omitempty"`
}

func (c *Client) GetCache(ctx context.Context, id string) (*Cache, error) {
    var cache Cache
    err := c.get(ctx, fmt.Sprintf("/v1/caches/%s", id), &cache)
    if err != nil {
        if IsNotFoundError(err) {
            return nil, &NotFoundError{
                ResourceType: "cache",
                ResourceID:   id,
            }
        }
        return nil, err
    }
    
    return &cache, nil
}

func (c *Client) CreateCache(ctx context.Context, input CreateCacheInput) (*Cache, error) {
    var cache Cache
    err := c.post(ctx, "/v1/caches", input, &cache)
    if err != nil {
        return nil, fmt.Errorf("failed to create cache: %w", err)
    }
    
    return &cache, nil
}

func (c *Client) UpdateCache(ctx context.Context, id string, input UpdateCacheInput) (*Cache, error) {
    var cache Cache
    err := c.patch(ctx, fmt.Sprintf("/v1/caches/%s", id), input, &cache)
    if err != nil {
        return nil, fmt.Errorf("failed to update cache: %w", err)
    }
    
    return &cache, nil
}

func (c *Client) DeleteCache(ctx context.Context, id string) error {
    err := c.delete(ctx, fmt.Sprintf("/v1/caches/%s", id))
    if err != nil {
        if IsNotFoundError(err) {
            // Already deleted
            return nil
        }
        return fmt.Errorf("failed to delete cache: %w", err)
    }
    
    return nil
}

func (c *Client) ListCaches(ctx context.Context) ([]Cache, error) {
    var response struct {
        Caches []Cache `json:"caches"`
    }
    
    err := c.get(ctx, "/v1/caches", &response)
    if err != nil {
        return nil, fmt.Errorf("failed to list caches: %w", err)
    }
    
    return response.Caches, nil
}

Step 6: Testing

Create client_test.go

package client

import (
    "context"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "time"
    
    "github.com/stretchr/testify/assert"
)

func TestClient_GetCache(t *testing.T) {
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        assert.Equal(t, "GET", r.Method)
        assert.Equal(t, "/v1/caches/cache-123", r.URL.Path)
        assert.Equal(t, "Bearer test-key", r.Header.Get("Authorization"))
        
        w.WriteHeader(http.StatusOK)
        json.NewEncoder(w).Encode(Cache{
            ID:     "cache-123",
            Name:   "test-cache",
            Engine: "redis",
        })
    }))
    defer server.Close()
    
    config := DefaultConfig()
    config.APIKey = "test-key"
    config.BaseURL = server.URL
    
    client, err := NewClient(config)
    assert.NoError(t, err)
    
    cache, err := client.GetCache(context.Background(), "cache-123")
    assert.NoError(t, err)
    assert.Equal(t, "cache-123", cache.ID)
    assert.Equal(t, "test-cache", cache.Name)
}

func TestClient_GetCache_NotFound(t *testing.T) {
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusNotFound)
    }))
    defer server.Close()
    
    config := DefaultConfig()
    config.APIKey = "test-key"
    config.BaseURL = server.URL
    
    client, err := NewClient(config)
    assert.NoError(t, err)
    
    _, err = client.GetCache(context.Background(), "cache-123")
    assert.Error(t, err)
    assert.True(t, IsNotFoundError(err))
}

func TestClient_Retry(t *testing.T) {
    attempts := 0
    
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        attempts++
        
        if attempts < 3 {
            w.WriteHeader(http.StatusInternalServerError)
            return
        }
        
        w.WriteHeader(http.StatusOK)
        json.NewEncoder(w).Encode(Cache{
            ID: "cache-123",
        })
    }))
    defer server.Close()
    
    config := DefaultConfig()
    config.APIKey = "test-key"
    config.BaseURL = server.URL
    config.MaxRetries = 3
    config.MinWait = 10 * time.Millisecond
    config.MaxWait = 100 * time.Millisecond
    
    client, err := NewClient(config)
    assert.NoError(t, err)
    
    cache, err := client.GetCache(context.Background(), "cache-123")
    assert.NoError(t, err)
    assert.Equal(t, "cache-123", cache.ID)
    assert.Equal(t, 3, attempts)
}

Verification Checklist

  • Client configuration with defaults
  • Custom error types with helpers
  • HTTP helpers (get, post, put, delete)
  • Exponential backoff with jitter
  • Rate limiting
  • Authentication in headers
  • Response parsing
  • Error parsing
  • Cache CRUD operations
  • Tests passing

Key Takeaways

You now have a production-ready API client with:

  • ✅ Configurable timeouts and retries
  • ✅ Rate limiting
  • ✅ Custom error types
  • ✅ Exponential backoff
  • ✅ Proper error handling
  • ✅ Comprehensive tests

What’s Next?

Test your knowledge with practice questions.

Next: API Client Practice →


Previous: ← Rate Limiting & Retries

Last updated: July 2, 2026

Updated: