Provider Middleware

9 minute read

Middleware Overview

Middleware provides a clean way to handle cross-cutting concerns like logging, retries, authentication, and metrics without cluttering business logic.

HTTP Middleware Pattern

RoundTripper Interface

// http.RoundTripper is the interface for HTTP middleware
type RoundTripper interface {
    RoundTrip(*http.Request) (*http.Response, error)
}

Middleware Chain

func NewClient(apiKey string) *Client {
    // Base transport
    base := &http.Transport{
        MaxIdleConns:    10,
        IdleConnTimeout: 30 * time.Second,
    }
    
    // Build middleware chain
    transport := chainMiddleware(base,
        NewLoggingMiddleware(),
        NewRetryMiddleware(3),
        NewMetricsMiddleware(),
        NewAuthMiddleware(apiKey),
    )
    
    return &Client{
        httpClient: &http.Client{
            Transport: transport,
            Timeout:   30 * time.Second,
        },
    }
}

func chainMiddleware(base http.RoundTripper, middleware ...Middleware) http.RoundTripper {
    transport := base
    // Apply in reverse order
    for i := len(middleware) - 1; i >= 0; i-- {
        transport = middleware[i].Wrap(transport)
    }
    return transport
}

Logging Middleware

Implementation

type LoggingMiddleware struct {
    next http.RoundTripper
}

func NewLoggingMiddleware() Middleware {
    return &LoggingMiddleware{}
}

func (m *LoggingMiddleware) Wrap(next http.RoundTripper) http.RoundTripper {
    m.next = next
    return m
}

func (m *LoggingMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
    ctx := req.Context()
    start := time.Now()
    
    // Log request
    tflog.Debug(ctx, "HTTP request",
        map[string]interface{}{
            "method": req.Method,
            "url":    req.URL.String(),
            "headers": sanitizeHeaders(req.Header),
        })
    
    // Execute request
    resp, err := m.next.RoundTrip(req)
    
    duration := time.Since(start)
    
    if err != nil {
        tflog.Error(ctx, "HTTP request failed",
            map[string]interface{}{
                "method":      req.Method,
                "url":         req.URL.String(),
                "error":       err.Error(),
                "duration_ms": duration.Milliseconds(),
            })
        return nil, err
    }
    
    // Log response
    tflog.Debug(ctx, "HTTP response",
        map[string]interface{}{
            "method":      req.Method,
            "url":         req.URL.String(),
            "status":      resp.StatusCode,
            "duration_ms": duration.Milliseconds(),
        })
    
    return resp, nil
}

func sanitizeHeaders(headers http.Header) map[string]string {
    sanitized := make(map[string]string)
    for key, values := range headers {
        // Mask sensitive headers
        if strings.ToLower(key) == "authorization" ||
           strings.ToLower(key) == "x-api-key" {
            sanitized[key] = "***REDACTED***"
        } else {
            sanitized[key] = strings.Join(values, ", ")
        }
    }
    return sanitized
}

Retry Middleware

Exponential Backoff

type RetryMiddleware struct {
    next       http.RoundTripper
    maxRetries int
    backoff    BackoffStrategy
}

type BackoffStrategy interface {
    NextDelay(attempt int) time.Duration
}

type ExponentialBackoff struct {
    BaseDelay time.Duration
    MaxDelay  time.Duration
}

func (b *ExponentialBackoff) NextDelay(attempt int) time.Duration {
    delay := b.BaseDelay * time.Duration(1<<uint(attempt))
    if delay > b.MaxDelay {
        delay = b.MaxDelay
    }
    // Add jitter
    jitter := time.Duration(rand.Int63n(int64(delay / 4)))
    return delay + jitter
}

func NewRetryMiddleware(maxRetries int) Middleware {
    return &RetryMiddleware{
        maxRetries: maxRetries,
        backoff: &ExponentialBackoff{
            BaseDelay: 100 * time.Millisecond,
            MaxDelay:  5 * time.Second,
        },
    }
}

func (m *RetryMiddleware) Wrap(next http.RoundTripper) http.RoundTripper {
    m.next = next
    return m
}

func (m *RetryMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
    ctx := req.Context()
    
    var resp *http.Response
    var err error
    
    for attempt := 0; attempt <= m.maxRetries; attempt++ {
        // Clone request for retry
        clonedReq := req.Clone(ctx)
        
        resp, err = m.next.RoundTrip(clonedReq)
        
        // Success or non-retryable error
        if err == nil && !isRetryableStatus(resp.StatusCode) {
            return resp, nil
        }
        
        // Don't retry on last attempt
        if attempt == m.maxRetries {
            break
        }
        
        // Determine if we should retry
        if !shouldRetry(resp, err) {
            break
        }
        
        // Calculate backoff
        delay := m.backoff.NextDelay(attempt)
        
        tflog.Debug(ctx, "Retrying request",
            map[string]interface{}{
                "attempt":     attempt + 1,
                "max_retries": m.maxRetries,
                "delay_ms":    delay.Milliseconds(),
            })
        
        // Wait before retry
        select {
        case <-time.After(delay):
            // Continue to next attempt
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
    
    return resp, err
}

func isRetryableStatus(statusCode int) bool {
    switch statusCode {
    case 429, // Too Many Requests
        500, // Internal Server Error
        502, // Bad Gateway
        503, // Service Unavailable
        504: // Gateway Timeout
        return true
    }
    return false
}

func shouldRetry(resp *http.Response, err error) bool {
    // Network error - retry
    if err != nil {
        if isTemporaryError(err) {
            return true
        }
        return false
    }
    
    // HTTP status - retry on server errors and rate limits
    return isRetryableStatus(resp.StatusCode)
}

func isTemporaryError(err error) bool {
    if netErr, ok := err.(net.Error); ok {
        return netErr.Temporary() || netErr.Timeout()
    }
    return false
}

Metrics Middleware

Request Metrics

type MetricsMiddleware struct {
    next http.RoundTripper
    
    // Metrics counters
    requestCount    *sync.Map // method+status -> count
    requestDuration *sync.Map // method -> []duration
    mu              sync.RWMutex
}

func NewMetricsMiddleware() Middleware {
    return &MetricsMiddleware{
        requestCount:    &sync.Map{},
        requestDuration: &sync.Map{},
    }
}

func (m *MetricsMiddleware) Wrap(next http.RoundTripper) http.RoundTripper {
    m.next = next
    return m
}

func (m *MetricsMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
    start := time.Now()
    
    resp, err := m.next.RoundTrip(req)
    
    duration := time.Since(start)
    
    // Record metrics
    m.recordRequest(req.Method, getStatusCode(resp, err), duration)
    
    return resp, err
}

func (m *MetricsMiddleware) recordRequest(method string, statusCode int, duration time.Duration) {
    // Increment request count
    key := fmt.Sprintf("%s:%d", method, statusCode)
    if val, ok := m.requestCount.Load(key); ok {
        count := val.(int64)
        m.requestCount.Store(key, count+1)
    } else {
        m.requestCount.Store(key, int64(1))
    }
    
    // Record duration
    if val, ok := m.requestDuration.Load(method); ok {
        durations := val.([]time.Duration)
        m.requestDuration.Store(method, append(durations, duration))
    } else {
        m.requestDuration.Store(method, []time.Duration{duration})
    }
}

func (m *MetricsMiddleware) GetMetrics() map[string]interface{} {
    metrics := make(map[string]interface{})
    
    // Request counts
    counts := make(map[string]int64)
    m.requestCount.Range(func(key, value interface{}) bool {
        counts[key.(string)] = value.(int64)
        return true
    })
    metrics["request_counts"] = counts
    
    // Duration stats
    durations := make(map[string]map[string]interface{})
    m.requestDuration.Range(func(key, value interface{}) bool {
        method := key.(string)
        durs := value.([]time.Duration)
        
        stats := calculateStats(durs)
        durations[method] = map[string]interface{}{
            "count":  len(durs),
            "min_ms": stats.Min.Milliseconds(),
            "max_ms": stats.Max.Milliseconds(),
            "avg_ms": stats.Avg.Milliseconds(),
            "p50_ms": stats.P50.Milliseconds(),
            "p95_ms": stats.P95.Milliseconds(),
            "p99_ms": stats.P99.Milliseconds(),
        }
        
        return true
    })
    metrics["durations"] = durations
    
    return metrics
}

type DurationStats struct {
    Min time.Duration
    Max time.Duration
    Avg time.Duration
    P50 time.Duration
    P95 time.Duration
    P99 time.Duration
}

func calculateStats(durations []time.Duration) DurationStats {
    if len(durations) == 0 {
        return DurationStats{}
    }
    
    // Sort for percentiles
    sorted := make([]time.Duration, len(durations))
    copy(sorted, durations)
    sort.Slice(sorted, func(i, j int) bool {
        return sorted[i] < sorted[j]
    })
    
    var total time.Duration
    for _, d := range sorted {
        total += d
    }
    
    return DurationStats{
        Min: sorted[0],
        Max: sorted[len(sorted)-1],
        Avg: total / time.Duration(len(sorted)),
        P50: sorted[len(sorted)*50/100],
        P95: sorted[len(sorted)*95/100],
        P99: sorted[len(sorted)*99/100],
    }
}

func getStatusCode(resp *http.Response, err error) int {
    if err != nil {
        return 0
    }
    return resp.StatusCode
}

Authentication Middleware

API Key Authentication

type AuthMiddleware struct {
    next   http.RoundTripper
    apiKey string
}

func NewAuthMiddleware(apiKey string) Middleware {
    return &AuthMiddleware{
        apiKey: apiKey,
    }
}

func (m *AuthMiddleware) Wrap(next http.RoundTripper) http.RoundTripper {
    m.next = next
    return m
}

func (m *AuthMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
    // Clone request to avoid modifying original
    clonedReq := req.Clone(req.Context())
    
    // Add authentication header
    clonedReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", m.apiKey))
    clonedReq.Header.Set("User-Agent", "terraform-provider-cachecloud/1.0")
    
    return m.next.RoundTrip(clonedReq)
}

OAuth2 Token Refresh

type OAuth2Middleware struct {
    next         http.RoundTripper
    tokenSource  oauth2.TokenSource
    mu           sync.RWMutex
    currentToken *oauth2.Token
}

func NewOAuth2Middleware(tokenSource oauth2.TokenSource) Middleware {
    return &OAuth2Middleware{
        tokenSource: tokenSource,
    }
}

func (m *OAuth2Middleware) Wrap(next http.RoundTripper) http.RoundTripper {
    m.next = next
    return m
}

func (m *OAuth2Middleware) RoundTrip(req *http.Request) (*http.Response, error) {
    // Get current token
    token, err := m.getToken()
    if err != nil {
        return nil, fmt.Errorf("failed to get auth token: %w", err)
    }
    
    // Clone and add auth header
    clonedReq := req.Clone(req.Context())
    clonedReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
    
    resp, err := m.next.RoundTrip(clonedReq)
    
    // If 401, try refreshing token once
    if err == nil && resp.StatusCode == http.StatusUnauthorized {
        // Force token refresh
        m.mu.Lock()
        m.currentToken = nil
        m.mu.Unlock()
        
        token, err = m.getToken()
        if err != nil {
            return resp, nil // Return original 401
        }
        
        // Retry with new token
        retryReq := req.Clone(req.Context())
        retryReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
        return m.next.RoundTrip(retryReq)
    }
    
    return resp, err
}

func (m *OAuth2Middleware) getToken() (*oauth2.Token, error) {
    m.mu.RLock()
    token := m.currentToken
    m.mu.RUnlock()
    
    // Check if we have a valid token
    if token != nil && token.Valid() {
        return token, nil
    }
    
    // Get new token
    m.mu.Lock()
    defer m.mu.Unlock()
    
    // Double-check after acquiring write lock
    if m.currentToken != nil && m.currentToken.Valid() {
        return m.currentToken, nil
    }
    
    token, err := m.tokenSource.Token()
    if err != nil {
        return nil, err
    }
    
    m.currentToken = token
    return token, nil
}

Rate Limiting Middleware

Token Bucket Implementation

type RateLimitMiddleware struct {
    next    http.RoundTripper
    limiter *rate.Limiter
}

func NewRateLimitMiddleware(requestsPerSecond float64, burst int) Middleware {
    return &RateLimitMiddleware{
        limiter: rate.NewLimiter(rate.Limit(requestsPerSecond), burst),
    }
}

func (m *RateLimitMiddleware) Wrap(next http.RoundTripper) http.RoundTripper {
    m.next = next
    return m
}

func (m *RateLimitMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
    ctx := req.Context()
    
    // Wait for rate limiter
    if err := m.limiter.Wait(ctx); err != nil {
        return nil, fmt.Errorf("rate limit wait failed: %w", err)
    }
    
    return m.next.RoundTrip(req)
}

Response Caching Middleware

Cache Implementation

type CachingMiddleware struct {
    next  http.RoundTripper
    cache *sync.Map
    ttl   time.Duration
}

type CachedResponse struct {
    StatusCode int
    Headers    http.Header
    Body       []byte
    ExpiresAt  time.Time
}

func NewCachingMiddleware(ttl time.Duration) Middleware {
    return &CachingMiddleware{
        cache: &sync.Map{},
        ttl:   ttl,
    }
}

func (m *CachingMiddleware) Wrap(next http.RoundTripper) http.RoundTripper {
    m.next = next
    return m
}

func (m *CachingMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
    // Only cache GET requests
    if req.Method != http.MethodGet {
        return m.next.RoundTrip(req)
    }
    
    cacheKey := req.URL.String()
    
    // Check cache
    if cached, found := m.cache.Load(cacheKey); found {
        cachedResp := cached.(*CachedResponse)
        if time.Now().Before(cachedResp.ExpiresAt) {
            tflog.Debug(req.Context(), "Cache hit", map[string]interface{}{
                "url": cacheKey,
            })
            return reconstructResponse(cachedResp), nil
        }
        m.cache.Delete(cacheKey)
    }
    
    // Execute request
    resp, err := m.next.RoundTrip(req)
    if err != nil {
        return nil, err
    }
    
    // Cache successful responses
    if resp.StatusCode == http.StatusOK {
        body, err := io.ReadAll(resp.Body)
        resp.Body.Close()
        if err != nil {
            return nil, err
        }
        
        // Store in cache
        m.cache.Store(cacheKey, &CachedResponse{
            StatusCode: resp.StatusCode,
            Headers:    resp.Header.Clone(),
            Body:       body,
            ExpiresAt:  time.Now().Add(m.ttl),
        })
        
        // Reconstruct response
        resp.Body = io.NopCloser(bytes.NewReader(body))
    }
    
    return resp, nil
}

func reconstructResponse(cached *CachedResponse) *http.Response {
    return &http.Response{
        StatusCode: cached.StatusCode,
        Header:     cached.Headers.Clone(),
        Body:       io.NopCloser(bytes.NewReader(cached.Body)),
    }
}

Complete Middleware Stack

Integration Example

package provider

import (
    "net/http"
    "time"
    
    "golang.org/x/time/rate"
)

func (p *provider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
    var config providerModel
    resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
    
    // Build HTTP client with middleware stack
    client := buildHTTPClient(
        config.APIKey.ValueString(),
        config.MaxRetries.ValueInt64(),
        config.RateLimit.ValueFloat64(),
    )
    
    // Create API client
    apiClient := NewAPIClient(client, config.BaseURL.ValueString())
    
    // Make available to resources and data sources
    resp.ResourceData = apiClient
    resp.DataSourceData = apiClient
}

func buildHTTPClient(apiKey string, maxRetries int64, rateLimit float64) *http.Client {
    // Base transport with connection pooling
    baseTransport := &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
        TLSHandshakeTimeout: 10 * time.Second,
    }
    
    // Build middleware chain (applied in reverse order)
    transport := chainMiddleware(baseTransport,
        NewLoggingMiddleware(),             // Innermost: logs after all processing
        NewMetricsMiddleware(),              // Collect metrics
        NewRetryMiddleware(int(maxRetries)), // Retry failed requests
        NewRateLimitMiddleware(rateLimit, 10), // Rate limit outgoing requests
        NewCachingMiddleware(5*time.Minute), // Cache GET responses
        NewAuthMiddleware(apiKey),          // Outermost: add auth to all requests
    )
    
    return &http.Client{
        Transport: transport,
        Timeout:   30 * time.Second,
    }
}

Best Practices

1. Middleware Order Matters

// ✅ Good: Logical ordering
Auth → Cache → RateLimit → Retry → Metrics → Logging

// ❌ Bad: Illogical ordering
Cache → Auth  // Cache before auth adds unauthenticated requests
Logging → Retry // Logs won't show retry attempts

2. Context Cancellation

// ✅ Good: Respect context
select {
case <-time.After(delay):
    // Continue
case <-ctx.Done():
    return nil, ctx.Err()
}

// ❌ Bad: Ignore context
time.Sleep(delay) // Blocks even if context cancelled

3. Thread Safety

// ✅ Good: Protect shared state
m.mu.Lock()
m.cache[key] = value
m.mu.Unlock()

// ❌ Bad: Race condition
m.cache[key] = value // Concurrent access without locking

Key Takeaways

  • ✅ Use middleware for cross-cutting concerns
  • ✅ Chain middleware in logical order
  • ✅ Implement logging for observability
  • ✅ Add retry logic with exponential backoff
  • ✅ Collect metrics for monitoring
  • ✅ Handle authentication consistently
  • ✅ Respect context cancellation
  • ✅ Ensure thread safety

What’s Next?

Learn advanced testing patterns for complex scenarios.

Next: Advanced Testing Patterns →


Previous: ← Custom Validation
Next: Advanced Testing Patterns →

Last updated: July 1, 2026

Updated: