API Client Practice

10 minute read

Practice Questions

Test your understanding of API client integration concepts.


Question 1: Client Configuration

Scenario: You need to create an HTTP client with proper timeouts.

Task: Configure an HTTP client with:

  • 60 second overall timeout
  • 10 second connection timeout
  • 20 second response header timeout
  • Connection pooling (100 max idle connections)
Click to see solution ```go package client import ( "net" "net/http" "time" ) func NewClient(apiKey string) (*Client, error) { transport := &http.Transport{ DialContext: (&net.Dialer{ Timeout: 10 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, ResponseHeaderTimeout: 20 * time.Second, MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, } httpClient := &http.Client{ Transport: transport, Timeout: 60 * time.Second, } return &Client{ httpClient: httpClient, apiKey: apiKey, }, nil } ``` **Key Points:** - `Dialer.Timeout` controls connection establishment - `ResponseHeaderTimeout` controls time to receive headers - `Client.Timeout` is the overall request timeout - `MaxIdleConns` enables connection pooling

Question 2: API Key Authentication

Scenario: Your API requires an API key in a custom header.

Task: Implement a method to add authentication to all requests with the header X-API-Key.

Click to see solution ```go // Option 1: Add in each request func (c *Client) makeRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) { url := c.baseURL + path var bodyReader io.Reader if body != nil { jsonBody, _ := json.Marshal(body) bodyReader = bytes.NewReader(jsonBody) } req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) if err != nil { return nil, err } // Add authentication req.Header.Set("X-API-Key", c.apiKey) req.Header.Set("Content-Type", "application/json") return c.httpClient.Do(req) } // Option 2: Use transport middleware type AuthTransport struct { Transport http.RoundTripper APIKey string } func (t *AuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) req.Header.Set("X-API-Key", t.APIKey) return t.Transport.RoundTrip(req) } func NewClient(apiKey string) (*Client, error) { baseTransport := &http.Transport{ MaxIdleConns: 100, } authTransport := &AuthTransport{ Transport: baseTransport, APIKey: apiKey, } return &Client{ httpClient: &http.Client{ Transport: authTransport, }, }, nil } ``` **Key Points:** - Transport middleware centralizes auth logic - Clone request to avoid modifying original - Transport approach is more maintainable

Question 3: Custom Error Types

Scenario: Create custom error types for API errors.

Task: Implement error types for NotFound, RateLimit, and Validation errors with helper functions.

Click to see solution ```go package client import "errors" // NotFound error 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) } // RateLimit 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)) } func (e *RateLimitError) IsRetryable() bool { return true } // Validation error type ValidationError struct { 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) } // Helper functions func IsNotFoundError(err error) bool { var notFoundErr *NotFoundError return errors.As(err, &notFoundErr) } func IsRateLimitError(err error) bool { var rateLimitErr *RateLimitError return errors.As(err, &rateLimitErr) } func IsValidationError(err error) bool { var valErr *ValidationError return errors.As(err, &valErr) } // Parse API error response func (c *Client) parseError(resp *http.Response, body []byte) error { switch resp.StatusCode { case 404: return &NotFoundError{ ResourceType: "resource", ResourceID: "unknown", } case 422: var response struct { Message string `json:"message"` Errors []FieldError `json:"errors"` } json.Unmarshal(body, &response) return &ValidationError{ Message: response.Message, Errors: response.Errors, } case 429: limit, _ := strconv.Atoi(resp.Header.Get("X-RateLimit-Limit")) remaining, _ := strconv.Atoi(resp.Header.Get("X-RateLimit-Remaining")) resetTime, _ := strconv.ParseInt(resp.Header.Get("X-RateLimit-Reset"), 10, 64) return &RateLimitError{ Limit: limit, Remaining: remaining, ResetAt: time.Unix(resetTime, 0), } default: return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) } } ``` **Key Points:** - Custom types enable type-safe error checking - `errors.As()` for type assertions - Parse rate limit info from headers - Include helpful error messages

Question 4: Exponential Backoff

Scenario: Implement retry logic with exponential backoff.

Task: Create a retry mechanism that:

  • Retries up to 3 times
  • Uses exponential backoff (1s, 2s, 4s)
  • Adds jitter (random 0-50% of wait time)
  • Respects context cancellation
Click to see solution ```go func (c *Client) doWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) { maxRetries := 3 baseDelay := 1 * time.Second var resp *http.Response var err error for attempt := 0; attempt <= maxRetries; attempt++ { // 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 == maxRetries { break } // Calculate exponential backoff wait := baseDelay * time.Duration(math.Pow(2, float64(attempt))) // Add jitter (random 0-50% of wait time) jitter := time.Duration(rand.Int63n(int64(wait) / 2)) totalWait := wait + jitter tflog.Debug(ctx, "Retrying request", map[string]interface{}{ "attempt": attempt + 1, "max_retries": maxRetries, "wait": totalWait.String(), }) // Wait with context cancellation support select { case <-time.After(totalWait): // Continue to retry case <-ctx.Done(): return nil, ctx.Err() } // Clone request body for retry if req.Body != nil && req.GetBody != nil { req.Body, _ = req.GetBody() } } 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 (429) if resp.StatusCode == 429 { return true } // Server errors (5xx) if resp.StatusCode >= 500 { return true } return false } ``` **Key Points:** - Exponential backoff: `baseDelay * 2^attempt` - Jitter prevents thundering herd problem - Check context cancellation in select - Only retry transient failures

Question 5: Rate Limiting

Scenario: Your API allows 100 requests per second.

Task: Implement rate limiting using golang.org/x/time/rate.

Click to see solution ```go import "golang.org/x/time/rate" type Client struct { httpClient *http.Client rateLimiter *rate.Limiter apiKey string } func NewClient(apiKey string) (*Client, error) { // Allow 100 requests per second with burst of 10 limiter := rate.NewLimiter(100, 10) return &Client{ httpClient: &http.Client{Timeout: 30 * time.Second}, rateLimiter: limiter, apiKey: apiKey, }, nil } func (c *Client) do(ctx context.Context, req *http.Request) (*http.Response, error) { // Wait for rate limit if err := c.rateLimiter.Wait(ctx); err != nil { return nil, fmt.Errorf("rate limiter cancelled: %w", err) } // Make request return c.httpClient.Do(req) } // Configurable rate limiting type RateLimitConfig struct { RequestsPerSecond float64 Burst int } func NewClientWithRateLimit(apiKey string, config RateLimitConfig) (*Client, error) { limiter := rate.NewLimiter( rate.Limit(config.RequestsPerSecond), config.Burst, ) return &Client{ httpClient: &http.Client{Timeout: 30 * time.Second}, rateLimiter: limiter, apiKey: apiKey, }, nil } ``` **Key Points:** - `rate.NewLimiter(rps, burst)` creates limiter - `limiter.Wait(ctx)` blocks until token available - Respects context cancellation - Burst allows temporary spikes

Question 6: OAuth 2.0 Client Credentials

Scenario: Implement OAuth 2.0 client credentials flow.

Task: Create a client that automatically handles OAuth token acquisition.

Click to see solution ```go import ( "golang.org/x/oauth2" "golang.org/x/oauth2/clientcredentials" ) type Client struct { httpClient *http.Client baseURL string } func NewClient(clientID, clientSecret, tokenURL, baseURL string) (*Client, error) { config := &clientcredentials.Config{ ClientID: clientID, ClientSecret: clientSecret, TokenURL: tokenURL, Scopes: []string{"read", "write"}, } // Create HTTP client with automatic token management httpClient := config.Client(context.Background()) return &Client{ httpClient: httpClient, baseURL: baseURL, }, nil } func (c *Client) makeRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) { url := c.baseURL + path var bodyReader io.Reader if body != nil { jsonBody, _ := json.Marshal(body) bodyReader = bytes.NewReader(jsonBody) } req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) if err != nil { return nil, err } // OAuth2 client automatically adds Authorization header return c.httpClient.Do(req) } // With manual token refresh type TokenClient struct { httpClient *http.Client token string refreshToken string tokenExpiry time.Time clientID string clientSecret string tokenURL string mutex sync.RWMutex } func (c *TokenClient) getValidToken(ctx context.Context) (string, error) { c.mutex.RLock() if time.Now().Before(c.tokenExpiry) { token := c.token c.mutex.RUnlock() return token, nil } c.mutex.RUnlock() return c.refreshAccessToken(ctx) } func (c *TokenClient) refreshAccessToken(ctx context.Context) (string, error) { c.mutex.Lock() defer c.mutex.Unlock() // Double-check after acquiring lock if time.Now().Before(c.tokenExpiry) { return c.token, nil } // Request new token body := map[string]string{ "grant_type": "client_credentials", "client_id": c.clientID, "client_secret": c.clientSecret, } resp, err := c.post(ctx, c.tokenURL, body, nil) if err != nil { return "", err } var tokenResp struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` } json.Unmarshal(resp, &tokenResp) c.token = tokenResp.AccessToken c.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) return c.token, nil } ``` **Key Points:** - Use `golang.org/x/oauth2` for automatic token management - `clientcredentials.Config` handles token acquisition - Token refresh happens automatically - Manual implementation requires mutex for thread safety

Question 7: Handle NotFound in Resources

Scenario: Implement proper NotFound handling in a resource’s Read method.

Task: When a resource is not found, remove it from state.

Click to see solution ```go func (r *CacheResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var state CacheModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } // Get cache from API cache, err := r.client.GetCache(ctx, state.ID.ValueString()) if err != nil { // Resource deleted outside Terraform if client.IsNotFoundError(err) { tflog.Info(ctx, "Cache not found, removing from state", map[string]interface{}{ "cache_id": state.ID.ValueString(), }) resp.State.RemoveResource(ctx) return } // Other errors resp.Diagnostics.AddError( "Failed to Read Cache", fmt.Sprintf("Unable to read cache %s: %s", state.ID.ValueString(), err.Error()), ) return } // Update state state.Name = types.StringValue(cache.Name) state.Engine = types.StringValue(cache.Engine) state.NodeType = types.StringValue(cache.NodeType) state.NumNodes = types.Int64Value(int64(cache.NumNodes)) state.Status = types.StringValue(cache.Status) resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } ``` **Key Points:** - Check for NotFoundError specifically - Call `resp.State.RemoveResource(ctx)` to remove from state - Log the removal for debugging - Handle other errors normally

Question 8: Testing with Mock Server

Scenario: Test your API client with a mock HTTP server.

Task: Create a test that verifies retry logic works correctly.

Click to see solution ```go func TestClient_Retry(t *testing.T) { attempts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ // Fail first 2 attempts with server error if attempts < 3 { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{ "error": "Internal server error", }) return } // Succeed on 3rd attempt w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(Cache{ ID: "cache-123", Name: "test-cache", }) })) defer server.Close() config := &ClientConfig{ APIKey: "test-key", BaseURL: server.URL, MaxRetries: 3, MinWait: 10 * time.Millisecond, 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, "Should have made 3 attempts") } func TestClient_RateLimit(t *testing.T) { requestTimes := []time.Time{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestTimes = append(requestTimes, time.Now()) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(Cache{ID: "cache-123"}) })) defer server.Close() // Rate limit: 10 requests per second config := &ClientConfig{ APIKey: "test-key", BaseURL: server.URL, RateLimit: 10.0, Burst: 1, } client, err := NewClient(config) assert.NoError(t, err) // Make 5 requests for i := 0; i < 5; i++ { _, _ = client.GetCache(context.Background(), "cache-123") } // Check spacing between requests for i := 1; i < len(requestTimes); i++ { gap := requestTimes[i].Sub(requestTimes[i-1]) // Should be at least 100ms apart (1s / 10 req/s) assert.GreaterOrEqual(t, gap, 90*time.Millisecond) } } ``` **Key Points:** - `httptest.NewServer` creates mock server - Track attempts to verify retry count - Use short wait times in tests - Verify rate limiting with time measurements

Question 9: Complete Client Integration

Scenario: Integrate the API client with your provider.

Task: Configure the API client in the provider’s Configure method.

Click to see solution ```go // Provider model type ProviderModel struct { APIKey types.String `tfsdk:"api_key"` Endpoint types.String `tfsdk:"endpoint"` Timeout types.Int64 `tfsdk:"timeout"` MaxRetries types.Int64 `tfsdk:"max_retries"` } // Configure provider func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { var config ProviderModel resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) if resp.Diagnostics.HasError() { return } // Get API key from config or environment apiKey := config.APIKey.ValueString() if apiKey == "" { apiKey = os.Getenv("CACHECLOUD_API_KEY") } if apiKey == "" { resp.Diagnostics.AddError( "Missing API Key", "Set api_key in provider configuration or CACHECLOUD_API_KEY environment variable", ) return } // Get endpoint endpoint := config.Endpoint.ValueString() if endpoint == "" { endpoint = "https://api.cachecloud.example.com" } // Create client configuration clientConfig := &client.Config{ APIKey: apiKey, BaseURL: endpoint, } // Set timeout if provided if !config.Timeout.IsNull() { clientConfig.Timeout = time.Duration(config.Timeout.ValueInt64()) * time.Second } // Set max retries if provided if !config.MaxRetries.IsNull() { clientConfig.MaxRetries = int(config.MaxRetries.ValueInt64()) } // Create client apiClient, err := client.NewClient(clientConfig) if err != nil { resp.Diagnostics.AddError( "Failed to Create Client", fmt.Sprintf("Unable to create API client: %s", err.Error()), ) return } // Validate credentials if err := apiClient.ValidateCredentials(ctx); err != nil { resp.Diagnostics.AddError( "Invalid Credentials", fmt.Sprintf("API key validation failed: %s", err.Error()), ) return } // Make client available to resources and data sources resp.ResourceData = apiClient resp.DataSourceData = apiClient } // Resource Configure method func (r *CacheResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { if req.ProviderData == nil { return } client, ok := req.ProviderData.(*client.Client) if !ok { resp.Diagnostics.AddError( "Unexpected Resource Configure Type", fmt.Sprintf("Expected *client.Client, got: %T", req.ProviderData), ) return } r.client = client } ``` **Key Points:** - Support environment variables as fallback - Validate credentials early - Make configuration options optional with defaults - Pass client to resources via ProviderData - Type assert in resource Configure method

Summary

You’ve practiced:

  • ✅ HTTP client configuration
  • ✅ Authentication patterns
  • ✅ Custom error types
  • ✅ Exponential backoff
  • ✅ Rate limiting
  • ✅ OAuth 2.0
  • ✅ Error handling in resources
  • ✅ Testing with mock servers
  • ✅ Provider integration

What’s Next?

Continue to Module 10: Testing Strategy.

Next: Module 10 Overview →


Previous: ← API Client Lab

Last updated: July 2, 2026

Updated: