API Client Lab
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, ¬FoundErr) || 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.
Previous: ← Rate Limiting & Retries
Last updated: July 2, 2026