API Error Handling
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, ¬FoundErr)
}
// 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, ¬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
}
// 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, ¬FoundErr)
}
// ❌ 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