When Error Codes Aren’t Enough: A Journey Through Multi-level Error Handling

Building robust cross-platform applications requires seamless communication between backend services and mobile clients. In our recent update to Menu Mate, a multilingual restaurant menu translation app, we encountered an intriguing challenge: despite sending the correct error code in our API response body, our mobile app was receiving generic HTTP 400 errors instead of our custom 1001 error code for non-menu images.

Advertisement

This seemingly simple issue revealed deeper insights into proper API error design and robust client-side error handling. Our journey to solve this problem offers valuable lessons for both beginners and experienced developers working on cross-platform applications.


The Problem: HTTP Status Codes vs. Application Error Codes

When a user uploaded a non-menu image to our application, our error flow should have been:

  1. Backend detects non-menu image
  2. Returns error code 1001 with an informative message
  3. Mobile app displays a friendly error to the user

However, our logs showed:

2025-04-30T16:45:11.043Z DEBUG: Raw service response: {"error": "not_a_menu"} 
2025-04-30T16:45:11.043Z DEBUG: Cleaned response: {"error": "not_a_menu"} 
2025-04-30T16:45:11.043Z WARNING: Non-menu image detected 
2025-04-30T16:45:11.043Z ERROR: Unexpected error: 400: {'code': 1001, 'message': '...'}
2025-04-30T16:45:11.043Z INFO: - "POST /api/process HTTP/1.1" 400 Bad Request

The issue: while our JSON response contained the correct error code (1001), it was being delivered with an HTTP status code of 400. Our client’s error handling wasn’t properly extracting the application-level error code from the response body.

The Layered Nature of API Errors

Before diving into the solution, it’s important to understand the two distinct layers of API error information:

  1. HTTP Status Codes: Protocol-level indicators (e.g., 200, 400, 500) that describe the general outcome of a request
  2. Application Error Codes: Custom codes within the response body that provide application-specific error details

These layers serve different purposes:

Layer Purpose Example Audience
HTTP Status Broad request outcome 400 Bad Request HTTP clients, network infrastructure
App Error Code Specific error condition 1001 (Not a menu) Application-specific logic

A common mistake is conflating these layers or relying exclusively on one. Our system correctly set the application error code but used a generic HTTP status code, and our client wasn’t adequately extracting the application error.

Server-Side: The Python Backend Implementation

Our backend was built with Python. The error detection was working correctly:

# Pseudocode
# Check if the response indicates a non-menu image
if is_dictionary(parsed_content) and parsed_content.get("error") == "not_a_menu":
    log_message("warning", "Non-menu image detected", client_id)
    raise HttpException(
        status_code=BAD_REQUEST_CODE,
        detail=create_error_response(
            code=ERROR_CODES["INVALID_IMAGE_NOT_MENU"],
            message="The uploaded image may not clearly show a restaurant menu..."
        )
    )

The issue was that we were using HTTP status code 400 (Bad Request) for all validation errors, including our custom “not a menu” error. While this is semantically correct from an HTTP perspective (the request is indeed “bad”), it made client-side error differentiation more challenging.

We fixed this by changing our approach:

# Pseudocode
# Instead of raising an exception, return a structured error response
if is_dictionary(parsed_content) and parsed_content.get("error") == "not_a_menu":
    log_message("warning", "Non-menu image detected", client_id)
    # Return a structured error object instead of throwing an exception
    return {
        "error_code": ERROR_CODES["INVALID_IMAGE_NOT_MENU"],
        "error_message": "The uploaded image may not clearly show a restaurant menu..."
    }

And in our endpoint:

# Pseudocode
# Call internal service
try:
    menu_data = await call_internal_service(user_content, max_tokens=4000, client_id=client_id)
    
    # Check for error response
    if is_dictionary(menu_data) and "error_code" in menu_data:
        log_message("info", "Returning error response for non-menu image", client_id)
        return JsonResponse(
            status_code=BAD_REQUEST_CODE,
            content=create_error_response(
                code=menu_data["error_code"],
                message=menu_data["error_message"]
            )
        )

This approach ensured that the HTTP status code remained 400 (which is semantically correct), while consistently structuring our error response with the specific error code.

Client-Side: Enhanced Mobile Error Handling

The more significant challenge was on the client side. Our original mobile code was correctly detecting the HTTP error but wasn’t properly extracting application error codes from multiple possible response formats:

// Pseudocode
// Handle non-200 status codes
if httpResponse.statusCode != 200 {
    // Try to parse the error response to extract code and message
    struct ErrorResponse {
        let code: Int
        let message: String
    }
    struct DetailErrorResponse {
        let detail: ErrorResponse
    }
    // ...limited parsing logic...
}

Our solution was to implement multi-stage error parsing that could handle various response formats:

// Pseudocode
if httpResponse.statusCode != 200 {
    // Try multiple parsing strategies
    do {
        // 1. Try standard API response format
        if let errorResponse = try? decoder.decode(ErrorResponse.self, from: data) {
            logger.info("Parsed error response with code: \(errorResponse.code)")
            
            if errorResponse.code == 1001 {
                throw AppError.invalidImageType(errorResponse.message)
            }
            throw AppError.serverError(errorResponse.message)
        }
        
        // 2. Try detail-wrapped response format
        if let detailResponse = try? decoder.decode(DetailErrorResponse.self, from: data) {
            logger.info("Parsed detail error response with code: \(detailResponse.detail.code)")
            
            if detailResponse.detail.code == 1001 {
                throw AppError.invalidImageType(detailResponse.detail.message)
            }
            throw AppError.serverError(detailResponse.detail.message)
        }
        
        // 3. Try generic JSON object
        if let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
           let code = jsonObject["code"] as? Int,
           let message = jsonObject["message"] as? String {
            
            logger.info("Parsed JSON object with code: \(code)")
            
            if code == 1001 {
                throw AppError.invalidImageType(message)
            }
            throw AppError.serverError(message)
        }
        
        // If all parsing attempts fail
        throw createErrorFromResponse(data, statusCode: httpResponse.statusCode)
    } catch let appError as AppError {
        // Pass through AppError types
        throw appError
    }
}

This multi-stage approach ensures we handle multiple error response formats, including:

  1. Our standard API response format
  2. A “detail” wrapped response
  3. Generic JSON objects with code/message pairs
  4. A fallback handler for unstructured responses

The Fallback Handler: Graceful Degradation

A key improvement was adding a fallback error handler that attempts to identify error patterns even when strict parsing fails:

// Pseudocode
private func createErrorFromResponse(_ data: Data, statusCode: Int) -> AppError {
    let errorString = String(data: data, encoding: .utf8) ?? "Unknown error"
    logger.error("Failed to parse error response: \(errorString)")
    
    // Special handling for known status codes
    if statusCode == 400 {
        // Try to check for 1001 code in the error string
        if errorString.contains("1001") && errorString.lowercased().contains("menu") {
            return AppError.invalidImageType(
                "The uploaded image does not appear to be a menu. Please try again with a clear photo of a restaurant menu."
            )
        }
    }
    
    return AppError.serverError("HTTP \(statusCode): \(errorString)")
}

This fallback method employs heuristic pattern matching to detect our special error cases even when standard parsing fails. It provides graceful degradation rather than showing a generic error message.

Lessons Learned: Best Practices for API Error Handling

Through this journey, we identified several best practices for robust error handling in cross-platform applications:

1. Layer Your Error Information

Use both HTTP status codes and application error codes, but for different purposes:

  • HTTP status codes for broad categories (400s for client errors, 500s for server errors)
  • Application error codes for specific error conditions

2. Create a Consistent Error Response Structure

All errors should follow a consistent format. Our standardized structure is:

{
  "code": 1001,
  "message": "User-friendly error message"
}

3. Implement Defensive Parsing on Clients

Mobile clients should:

  • Handle multiple error response formats
  • Implement fallback handling for unparseable responses
  • Log detailed error information for debugging
  • Present user-friendly error messages

4. Use Explicit Custom Error Types

Create custom error enumerations or classes:

// Pseudocode
enum AppError {
    case invalidInput(String?)
    case invalidImageType(String)
    case serverError(String)
    case parsingError(String)

    var userMessage: String {
        switch self {
        case .invalidInput(let message):
            return "Invalid input: \(message ?? "Unknown error")"
        case .invalidImageType(let message):
            return message // Use server-provided message for alert
        case .serverError(let message):
            return "Server error: \(message)"
        case .parsingError(let message):
            return "Processing error: \(message)"
        }
    }
}

This approach allows for type-safe error handling and simplifies UI-specific error responses.

Advanced Technique: Error Response Pattern Matching

For complex applications with many error types, consider using pattern matching to handle different error scenarios:

// Pseudocode
func handleAPIError(_ error: Error) {
    switch error {
    case let appError as AppError:
        switch appError {
        case .invalidImageType(let message):
            presentCustomAlert(message: message)
        case .invalidInput(let details):
            presentInputErrorAlert(details: details)
        case .serverError(let message) where message.contains("authentication"):
            refreshTokenAndRetry()
        case .serverError:
            presentGenericErrorAlert()
        case .parsingError:
            logAndPresentTechnicalError()
        }
    case let networkError as NetworkError where networkError.code == .noConnection:
        presentOfflineAlert()
    default:
        presentUnexpectedErrorAlert()
    }
}

This enables sophisticated and context-specific error handling that improves user experience.

Testing Error Handling: A Comprehensive Approach

Robust error handling requires thorough testing. We implemented:

  1. Unit tests for error parsing logic
  2. Mocked API responses with various error structures
  3. UI tests that verify proper error message display
  4. Logging integration to capture client-side error details

Our testing suite includes scenarios for:

  • Network timeouts
  • Malformed JSON responses
  • Expected application errors (like non-menu images)
  • Unexpected server errors

Conclusion: Layered Defense for Robust Applications

The journey from HTTP errors to rich, contextual error handling illustrates the importance of a layered approach to API design. By implementing multiple levels of error detection, parsing, and handling, we’ve created a more robust, user-friendly experience.

Remember:

  • Error handling is not just about preventing crashes—it’s about guiding users
  • Multiple parsing strategies provide defense in depth
  • Consistent error structures simplify cross-platform development
  • Graceful degradation should always be the default approach

By sharing these lessons, we hope to help other developers avoid similar pitfalls and build more resilient applications. After all, great error handling is invisible when working correctly, but absolutely critical when things go wrong.

Happy coding!