Skip to main content
The mobile package provides structured error handling with consistent response formats, error codes, and detailed context for mobile clients.

Quick Start

import "github.com/go-mizu/mizu/mobile"

func handler(c *mizu.Ctx) error {
    if !authorized {
        return mobile.SendError(c, 401, mobile.NewError(
            mobile.ErrUnauthorized,
            "Invalid credentials",
        ).WithDetails("reason", "token_expired"))
    }

    return c.JSON(200, data)
}
Response:
{
  "code": "unauthorized",
  "message": "Invalid credentials",
  "details": {
    "reason": "token_expired"
  },
  "trace_id": "req-abc-123"
}

Error Codes

Standard error codes for mobile clients:
const (
    ErrInvalidRequest   = "invalid_request"
    ErrUnauthorized     = "unauthorized"
    ErrForbidden        = "forbidden"
    ErrNotFound         = "not_found"
    ErrConflict         = "conflict"
    ErrValidation       = "validation_error"
    ErrRateLimited      = "rate_limited"
    ErrUpgradeRequired  = "upgrade_required"
    ErrMaintenance      = "maintenance"
    ErrInternal         = "internal_error"
)

The Error Type

type Error struct {
    Code     string         `json:"code"`
    Message  string         `json:"message"`
    Details  map[string]any `json:"details,omitempty"`
    TraceID  string         `json:"trace_id,omitempty"`
}

Creating Errors

// Basic error
err := mobile.NewError(mobile.ErrValidation, "Email is invalid")

// With details
err := mobile.NewError(mobile.ErrValidation, "Validation failed").
    WithDetails("field", "email").
    WithDetails("error", "invalid format")

// With trace ID
err := mobile.NewError(mobile.ErrInternal, "Something went wrong").
    WithTraceID(c.Request().Header.Get("X-Request-ID"))

Error Methods

err := mobile.NewError(mobile.ErrValidation, "Validation failed")

// Add single detail
err = err.WithDetails("field", "email")

// Add trace ID
err = err.WithTraceID("req-123")

// Check error type
err.Code == mobile.ErrValidation  // true

Sending Errors

Basic Usage

func handler(c *mizu.Ctx) error {
    return mobile.SendError(c, 400, mobile.NewError(
        mobile.ErrInvalidRequest,
        "Missing required field",
    ))
}

With HTTP Status Codes

Error CodeHTTP StatusWhen to Use
invalid_request400Bad request format
validation_error400Field validation failed
unauthorized401Authentication required
forbidden403Permission denied
not_found404Resource not found
conflict409Resource conflict
rate_limited429Too many requests
upgrade_required426App update needed
maintenance503Service unavailable
internal_error500Server error

Common Patterns

Authentication Errors

func authMiddleware() mizu.Middleware {
    return func(next mizu.Handler) mizu.Handler {
        return func(c *mizu.Ctx) error {
            token := c.Request().Header.Get("Authorization")

            if token == "" {
                return mobile.SendError(c, 401, mobile.NewError(
                    mobile.ErrUnauthorized,
                    "Authentication required",
                ).WithDetails("reason", "missing_token"))
            }

            user, err := validateToken(token)
            if err != nil {
                return mobile.SendError(c, 401, mobile.NewError(
                    mobile.ErrUnauthorized,
                    "Invalid token",
                ).WithDetails("reason", err.Error()))
            }

            // Continue with authenticated user
            return next(c)
        }
    }
}

Validation Errors

type CreateUserRequest struct {
    Email    string `json:"email"`
    Password string `json:"password"`
    Name     string `json:"name"`
}

func createUser(c *mizu.Ctx) error {
    var req CreateUserRequest
    if err := c.BodyJSON(&req); err != nil {
        return mobile.SendError(c, 400, mobile.NewError(
            mobile.ErrInvalidRequest,
            "Invalid JSON body",
        ))
    }

    // Validate fields
    errors := make(map[string]string)

    if req.Email == "" {
        errors["email"] = "required"
    } else if !isValidEmail(req.Email) {
        errors["email"] = "invalid format"
    }

    if len(req.Password) < 8 {
        errors["password"] = "minimum 8 characters"
    }

    if req.Name == "" {
        errors["name"] = "required"
    }

    if len(errors) > 0 {
        return mobile.SendError(c, 400, mobile.NewError(
            mobile.ErrValidation,
            "Validation failed",
        ).WithDetails("errors", errors))
    }

    // Create user...
    return c.JSON(201, user)
}
Response:
{
  "code": "validation_error",
  "message": "Validation failed",
  "details": {
    "errors": {
      "email": "invalid format",
      "password": "minimum 8 characters"
    }
  }
}

Not Found Errors

func getUser(c *mizu.Ctx) error {
    id := c.Param("id")

    user, err := db.GetUser(id)
    if err == sql.ErrNoRows {
        return mobile.SendError(c, 404, mobile.NewError(
            mobile.ErrNotFound,
            "User not found",
        ).WithDetails("id", id))
    }
    if err != nil {
        return mobile.SendError(c, 500, mobile.NewError(
            mobile.ErrInternal,
            "Failed to fetch user",
        ))
    }

    return c.JSON(200, user)
}

Rate Limiting Errors

func rateLimitHandler(c *mizu.Ctx) error {
    return mobile.SendError(c, 429, mobile.NewError(
        mobile.ErrRateLimited,
        "Too many requests",
    ).WithDetails("retry_after", 60).
      WithDetails("limit", 100).
      WithDetails("remaining", 0))
}

Upgrade Required

func checkVersion(c *mizu.Ctx) error {
    device := mobile.DeviceFromCtx(c)

    if mobile.CompareVersions(device.AppVersion, "2.0.0") < 0 {
        return mobile.SendError(c, 426, mobile.NewError(
            mobile.ErrUpgradeRequired,
            "Please update to the latest version",
        ).WithDetails("current_version", device.AppVersion).
          WithDetails("minimum_version", "2.0.0").
          WithDetails("store_url", getStoreURL(device.Platform)))
    }

    return nil
}

Maintenance Mode

func maintenanceMiddleware(enabled bool, endTime time.Time) mizu.Middleware {
    return func(next mizu.Handler) mizu.Handler {
        return func(c *mizu.Ctx) error {
            if enabled {
                return mobile.SendError(c, 503, mobile.NewError(
                    mobile.ErrMaintenance,
                    "Service is under maintenance",
                ).WithDetails("end_time", endTime.Format(time.RFC3339)).
                  WithDetails("message", "We'll be back shortly"))
            }
            return next(c)
        }
    }
}

Error Response Format

All errors follow a consistent format:
{
  "code": "error_code",
  "message": "Human-readable message",
  "details": {
    "field1": "value1",
    "field2": "value2"
  },
  "trace_id": "request-trace-id"
}

Adding Trace IDs

app.Use(func(next mizu.Handler) mizu.Handler {
    return func(c *mizu.Ctx) error {
        // Get or generate trace ID
        traceID := c.Request().Header.Get("X-Request-ID")
        if traceID == "" {
            traceID = generateTraceID()
        }

        // Store in context for error handlers
        c.Set("trace_id", traceID)

        // Add to response header
        c.Header().Set("X-Request-ID", traceID)

        return next(c)
    }
})

func handler(c *mizu.Ctx) error {
    traceID := c.Get("trace_id").(string)

    return mobile.SendError(c, 500, mobile.NewError(
        mobile.ErrInternal,
        "Something went wrong",
    ).WithTraceID(traceID))
}

Client Implementation

iOS (Swift)

struct APIError: Decodable {
    let code: String
    let message: String
    let details: [String: AnyCodable]?
    let traceId: String?

    enum CodingKeys: String, CodingKey {
        case code, message, details
        case traceId = "trace_id"
    }
}

enum APIErrorCode: String {
    case invalidRequest = "invalid_request"
    case unauthorized = "unauthorized"
    case forbidden = "forbidden"
    case notFound = "not_found"
    case validation = "validation_error"
    case rateLimited = "rate_limited"
    case upgradeRequired = "upgrade_required"
    case maintenance = "maintenance"
    case internalError = "internal_error"
}

class APIClient {
    func handleError(_ data: Data, statusCode: Int) -> Error {
        guard let error = try? JSONDecoder().decode(APIError.self, from: data) else {
            return GenericError(message: "Unknown error", code: statusCode)
        }

        switch APIErrorCode(rawValue: error.code) {
        case .unauthorized:
            return AuthError.unauthorized(error.message)
        case .upgradeRequired:
            let storeURL = error.details?["store_url"]?.value as? String
            return UpgradeRequiredError(storeURL: storeURL)
        case .validation:
            let errors = error.details?["errors"]?.value as? [String: String]
            return ValidationError(fields: errors ?? [:])
        default:
            return GenericError(message: error.message, code: statusCode)
        }
    }
}

Android (Kotlin)

data class ApiError(
    val code: String,
    val message: String,
    val details: Map<String, Any>? = null,
    @SerializedName("trace_id") val traceId: String? = null
)

sealed class ApiException(message: String) : Exception(message) {
    class Unauthorized(message: String) : ApiException(message)
    class NotFound(message: String) : ApiException(message)
    class ValidationError(val errors: Map<String, String>) : ApiException("Validation failed")
    class UpgradeRequired(val storeUrl: String?) : ApiException("Update required")
    class Maintenance(val endTime: String?) : ApiException("Under maintenance")
    class Unknown(message: String) : ApiException(message)
}

fun parseError(response: Response): ApiException {
    val error = response.body?.let {
        gson.fromJson(it.string(), ApiError::class.java)
    } ?: return ApiException.Unknown("Unknown error")

    return when (error.code) {
        "unauthorized" -> ApiException.Unauthorized(error.message)
        "not_found" -> ApiException.NotFound(error.message)
        "validation_error" -> {
            @Suppress("UNCHECKED_CAST")
            val errors = error.details?.get("errors") as? Map<String, String> ?: emptyMap()
            ApiException.ValidationError(errors)
        }
        "upgrade_required" -> {
            val storeUrl = error.details?.get("store_url") as? String
            ApiException.UpgradeRequired(storeUrl)
        }
        "maintenance" -> {
            val endTime = error.details?.get("end_time") as? String
            ApiException.Maintenance(endTime)
        }
        else -> ApiException.Unknown(error.message)
    }
}

Flutter (Dart)

class ApiError {
  final String code;
  final String message;
  final Map<String, dynamic>? details;
  final String? traceId;

  ApiError({
    required this.code,
    required this.message,
    this.details,
    this.traceId,
  });

  factory ApiError.fromJson(Map<String, dynamic> json) {
    return ApiError(
      code: json['code'],
      message: json['message'],
      details: json['details'],
      traceId: json['trace_id'],
    );
  }
}

class ApiException implements Exception {
  final ApiError error;
  final int statusCode;

  ApiException(this.error, this.statusCode);

  bool get isUnauthorized => error.code == 'unauthorized';
  bool get isNotFound => error.code == 'not_found';
  bool get isValidationError => error.code == 'validation_error';
  bool get isUpgradeRequired => error.code == 'upgrade_required';
  bool get isMaintenance => error.code == 'maintenance';

  Map<String, String>? get validationErrors {
    if (!isValidationError) return null;
    return (error.details?['errors'] as Map?)?.cast<String, String>();
  }

  String? get storeUrl => error.details?['store_url'];
}

Best Practices

Use Appropriate Status Codes

  • 400 - Client error (bad input)
  • 401 - Authentication required
  • 403 - Permission denied
  • 404 - Resource not found
  • 409 - Conflict (duplicate, etc.)
  • 422 - Validation error (alternative to 400)
  • 426 - Upgrade required
  • 429 - Rate limited
  • 500 - Server error
  • 503 - Maintenance

Be Specific in Error Messages

// Bad
mobile.NewError(mobile.ErrValidation, "Invalid input")

// Good
mobile.NewError(mobile.ErrValidation, "Email address is invalid").
    WithDetails("field", "email").
    WithDetails("provided", user.Email).
    WithDetails("expected", "valid email format")

Include Actionable Information

// Rate limit with retry info
mobile.NewError(mobile.ErrRateLimited, "Too many requests").
    WithDetails("retry_after", 60)

// Upgrade with store URL
mobile.NewError(mobile.ErrUpgradeRequired, "Update required").
    WithDetails("store_url", "https://apps.apple.com/...")

Log Errors Server-Side

func errorLogger() mizu.Middleware {
    return func(next mizu.Handler) mizu.Handler {
        return func(c *mizu.Ctx) error {
            err := next(c)
            if err != nil {
                log.Error("Request failed",
                    "error", err,
                    "path", c.Request().URL.Path,
                    "method", c.Request().Method,
                    "device", mobile.DeviceFromCtx(c).DeviceID,
                )
            }
            return err
        }
    }
}

Next Steps