DEV Community

Pavel Andreev
Pavel Andreev

Posted on

Swift: The Complete Guide to Error Handling in the Network Layer

In my previous article, we explored how to construct a robust, abstract network layer using Clean Architecture. The response was fantastic, but I received a recurring piece of feedback: the error handling was a bit too thin for a real-world production environment.

Categorizing HTTP Status Codes

To provide a more granular and descriptive way of handling network events, I decided to categorize HTTP status codes into specific enums. This approach ensures that our logic is both type-safe and highly readable. By referencing the MDN Web Docs, I mapped out each response category to its own structure.

This categorization allows us to handle informational updates, successful transfers, and various error types with specialized logic rather than a giant, messy switch statement.

The Unified Interface: HTTPResponseDescription

Before diving into the specific error groups, we need a “blueprint.” The HTTPResponseDescription protocol ensures that every response type in our system, regardless of its origin, exposes two critical pieces of information: the numeric status code and a human-readable description.

This is the “secret sauce” that allows our UI layer to display meaningful messages to the user without needing to know the technical details of the error.

protocol HTTPResponseDescription {
    var statusCode: Int { get }
    var description: String { get }
}
Enter fullscreen mode Exit fullscreen mode

Handling System-Level Failures: NSURLErrorCode

While HTTP status codes (like 404 or 500) tell us what the server thinks, sometimes the request doesn’t even reach the server. This happens when the URL is malformed, the connection times out, or the internet is simply gone.

To handle these “pre-response” failures, I created the NSURLErrorCode enum. By conforming it to our HTTPResponseDescription protocol, we can handle these low-level network issues using the exact same pattern as our HTTP responses.

enum NSURLErrorCode: Error, HTTPResponseDescription {

    case unknown
    case invalidResponse
    case badURL
    case timedOut
    case decodingError
    case outOfRange(Int)

    init(code: Int) {
        switch code {
        case 0: self = .unknown
        case 1: self = .invalidResponse
        case 2: self = .badURL
        case 3: self = .timedOut
        default:  self = .outOfRange(code)
        }
    }

    var statusCode: Int {
        switch self {
        case .unknown: return 0
        case .invalidResponse: return 1
        case .badURL: return 2
        case .timedOut: return 3
        case .decodingError: return 4
        case .outOfRange(let code): return code
        }
    }

    var description: String {
        switch self {
        case .badURL: return "The URL was malformed."
        case .invalidResponse: return "Invalid response"
        case .decodingError: return "Failed to decode the response."
        case .outOfRange(let statusCode): return "The request \(statusCode) was out of range."
        case .unknown: return "An unknown error occurred."
        case .timedOut: return "The request timed out."

        }
    }
}
Enter fullscreen mode Exit fullscreen mode

1xx: Informational Responses

The first group represents Informational Responses, which indicate that the request was received and the process is continuing.

/// 1..x
enum InformationalResponse: Error, HTTPResponseDescription {
    case continueResponse
    case switchingProtocols
    case processingDeprecated
    case earlyHints
    case unknown(Int)

    init(code: Int) {
        switch code {
        case 100: self = .continueResponse
        case 101: self = .switchingProtocols
        case 102: self = .processingDeprecated
        case 103: self = .earlyHints
        default:  self = .unknown(code)
        }
    }

    var statusCode: Int {
        switch self {
        case .continueResponse:     return 100
        case .switchingProtocols:   return 101
        case .processingDeprecated: return 102
        case .earlyHints:           return 103
        case .unknown(let code):    return code
        }
    }

    var description: String {
        switch self {
        case .continueResponse:     return "Continue"
        case .switchingProtocols:   return "Switching Protocols"
        case .processingDeprecated: return "Processing"
        case .earlyHints:           return "Early Hints"
        case .unknown(let code):    return "Unknown code: \(code)"
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

2xx: Successful Responses

While we often focus on handling errors, understanding the nuances of success is equally important for a high-quality network layer. The 2xx category indicates that the client’s request was successfully received, understood, and accepted.

While a simple 200 OK is the most common response, other codes like 201 Created (essential for POST requests) or 204 No Content (common for DELETE operations) provide critical context to your business logic. By explicitly mapping these, we can trigger specific UI updates—like navigating back after a successful creation—with absolute certainty.

/// 2xx Success: The action was successfully received, understood, and accepted.
enum SuccessfulResponses: Error, Equatable, HTTPResponseDescription {
    case ok
    case created
    case accepted
    case nonAuthoritativeInformation
    case noContent
    case resetContent
    case partialContent
    case multiStatus
    case alreadyReported
    case imUsed
    case unknown(Int)

    init(code: Int) {
        switch code {
        case 200: self = .ok
        case 201: self = .created
        case 202: self = .accepted
        case 203: self = .nonAuthoritativeInformation
        case 204: self = .noContent
        case 205: self = .resetContent
        case 206: self = .partialContent
        case 207: self = .multiStatus
        case 208: self = .alreadyReported
        case 226: self = .imUsed
        default:  self = .unknown(code)
        }
    }

    var statusCode: Int {
        switch self {
        case .ok:                           return 200
        case .created:                      return 201
        case .accepted:                     return 202
        case .nonAuthoritativeInformation:  return 203
        case .noContent:                    return 204
        case .resetContent:                 return 205
        case .partialContent:               return 206
        case .multiStatus:                  return 207
        case .alreadyReported:              return 208
        case .imUsed:                       return 226
        case .unknown(let code):            return code
        }
    }

    var description: String {
        switch self {
        case .ok:                           return "OK"
        case .created:                      return "Created"
        case .accepted:                     return "Accepted"
        case .nonAuthoritativeInformation:  return "Non-Authoritative Information"
        case .noContent:                    return "No Content"
        case .resetContent:                 return "Reset Content"
        case .partialContent:               return "Partial Content"
        case .multiStatus:                  return "Multi-Status"
        case .alreadyReported:              return "Already Reported"
        case .imUsed:                       return "IM Used"
        case .unknown(let code):            return "Unknown Success code: \(code)"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

3xx: Redirection Messages

The 3xx category of status codes indicates that the client must take additional action to complete the request. In many cases, URLSession handles these redirects automatically under the hood. However, being able to explicitly identify them is vital for advanced scenarios, such as optimizing cache performance with 304 Not Modified or debugging unexpected URL changes.

By including redirection messages in our service, we gain full visibility into the “hops” our network requests take before reaching their final destination. This is particularly useful when working with legacy APIs or complex content delivery networks (CDNs).

/// 3xx Redirection: Further action needs to be taken by the user agent to fulfill the request.
enum RedirectionMessages: Error, HTTPResponseDescription {
    case useProxy
    case found
    case seeOther
    case notModified
    case useProxyForAuthentication
    case temporaryRedirect
    case permanentRedirect
    case unknown(Int)

    init(code: Int) {
        switch code {
        case 300: self = .useProxy
        case 302: self = .found
        case 303: self = .seeOther
        case 304: self = .notModified
        case 305: self = .useProxyForAuthentication
        case 307: self = .temporaryRedirect
        case 308: self = .permanentRedirect
        default:  self = .unknown(code)
        }
    }

    var statusCode: Int {
        switch self {
        case .useProxy:                   return 300
        case .found:                      return 302
        case .seeOther:                   return 303
        case .notModified:                return 304
        case .useProxyForAuthentication:  return 305
        case .temporaryRedirect:          return 307
        case .permanentRedirect:          return 308
        case .unknown(let code):          return code
        }
    }

    var description: String {
        switch self {
        case .useProxy:
            return "Multiple Choices" 
        case .found:
            return "Found"
        case .seeOther:
            return "See Other"
        case .notModified:
            return "Not Modified"
        case .useProxyForAuthentication:
            return "Use Proxy"
        case .temporaryRedirect:
            return "Temporary Redirect"
        case .permanentRedirect:
            return "Permanent Redirect"
        case .unknown(let code):
            return "Unknown Redirection code: \(code)"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

4xx: Client Error Responses

This is where things get interesting — and where your app’s logic needs to be the sharpest. The 4xx category represents errors where the request contains bad syntax or cannot be fulfilled. In short: the client (your app) did something the server didn’t like, or the user needs to provide more information.

Properly handling 4xx errors is the difference between an app that just says “Error” and one that intelligently guides the user. For instance, a 401 Unauthorized should trigger a login flow, while a 429 Too Many Requests should tell the user to slow down rather than spamming the retry button.

/// 4xx Client Error: The request contains bad syntax or cannot be fulfilled.
enum ClientErrorResponses: Error, HTTPResponseDescription {
    case badRequest
    case unauthorized
    case forbidden
    case notFound
    case methodNotAllowed
    case notAcceptable
    case proxyAuthenticationRequired
    case requestTimeout
    case conflict
    case gone
    case lengthRequired
    case preconditionFailed
    case payloadTooLarge
    case URITooLong
    case unsupportedMediaType
    case rangeNotSatisfiable
    case expectationFailed
    case misdirectedRequest
    case unProcessableEntity
    case locked
    case failedDependency
    case upgradeRequired
    case preconditionRequired
    case tooManyRequests
    case requestHeaderFieldsTooLarge
    case unavailableForLegalReasons
    case unknown(Int)

    init(code: Int) {
        switch code {
        case 400: self = .badRequest
        case 401: self = .unauthorized
        case 403: self = .forbidden
        case 404: self = .notFound
        case 405: self = .methodNotAllowed
        case 406: self = .notAcceptable
        case 407: self = .proxyAuthenticationRequired
        case 408: self = .requestTimeout
        case 409: self = .conflict
        case 410: self = .gone
        case 411: self = .lengthRequired
        case 412: self = .preconditionFailed
        case 413: self = .payloadTooLarge
        case 414: self = .URITooLong
        case 415: self = .unsupportedMediaType
        case 416: self = .rangeNotSatisfiable
        case 417: self = .expectationFailed
        case 421: self = .misdirectedRequest
        case 422: self = .unProcessableEntity
        case 423: self = .locked
        case 424: self = .failedDependency
        case 426: self = .upgradeRequired
        case 428: self = .preconditionRequired
        case 429: self = .tooManyRequests
        case 431: self = .requestHeaderFieldsTooLarge
        case 451: self = .unavailableForLegalReasons
        default:  self = .unknown(code)
        }
    }

    var statusCode: Int {
        switch self {
        case .badRequest:                   return 400
        case .unauthorized:                 return 401
        case .forbidden:                    return 403
        case .notFound:                     return 404
        case .methodNotAllowed:             return 405
        case .notAcceptable:                return 406
        case .proxyAuthenticationRequired:  return 407
        case .requestTimeout:               return 408
        case .conflict:                     return 409
        case .gone:                         return 410
        case .lengthRequired:               return 411
        case .preconditionFailed:           return 412
        case .payloadTooLarge:              return 413
        case .URITooLong:                   return 414
        case .unsupportedMediaType:         return 415
        case .rangeNotSatisfiable:          return 416
        case .expectationFailed:            return 417
        case .misdirectedRequest:           return 421
        case .unProcessableEntity:          return 422
        case .locked:                       return 423
        case .failedDependency:             return 424
        case .upgradeRequired:              return 426
        case .preconditionRequired:         return 428
        case .tooManyRequests:              return 429
        case .requestHeaderFieldsTooLarge:  return 431
        case .unavailableForLegalReasons:   return 451
        case .unknown(let code):            return code
        }
    }

    var description: String {
        switch self {
        case .badRequest:                   return "Bad Request"
        case .unauthorized:                 return "Unauthorized"
        case .forbidden:                    return "Forbidden"
        case .notFound:                     return "Not Found"
        case .methodNotAllowed:             return "Method Not Allowed"
        case .notAcceptable:                return "Not Acceptable"
        case .proxyAuthenticationRequired:  return "Proxy Authentication Required"
        case .requestTimeout:               return "Request Timeout"
        case .conflict:                     return "Conflict"
        case .gone:                         return "Gone"
        case .lengthRequired:               return "Length Required"
        case .preconditionFailed:           return "Precondition Failed"
        case .payloadTooLarge:              return "Payload Too Large"
        case .URITooLong:                   return "URI Too Long"
        case .unsupportedMediaType:         return "Unsupported Media Type"
        case .rangeNotSatisfiable:          return "Range Not Satisfiable"
        case .expectationFailed:            return "Expectation Failed"
        case .misdirectedRequest:           return "Misdirected Request"
        case .unProcessableEntity:          return "Unprocessable Entity"
        case .locked:                       return "Locked"
        case .failedDependency:             return "Failed Dependency"
        case .upgradeRequired:              return "Upgrade Required"
        case .preconditionRequired:         return "Precondition Required"
        case .tooManyRequests:              return "Too Many Requests"
        case .requestHeaderFieldsTooLarge:  return "Request Header Fields Too Large"
        case .unavailableForLegalReasons:   return "Unavailable For Legal Reasons"
        case .unknown(let code):            return "Unknown Client Error code: \(code)"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

5xx: Server Error Responses

The 5xx category is the server’s way of saying, “It’s not you, it’s me.” These status codes indicate cases where the server is aware that it has encountered an error or is otherwise incapable of performing the request.

For an iOS developer, handling 5xx errors correctly is crucial for app stability. While a 4xx error might suggest a bug in your request logic, a 5xx error usually means the backend is having a bad day. Identifying a 503 Service Unavailable versus a 504 Gateway Timeout allows you to decide whether to trigger an immediate retry or to show a "Maintenance" screen to the user.

/// 5xx Server Error: The server failed to fulfill an apparently valid request.
enum ServerErrorResponses: Error, HTTPResponseDescription {
    case internalServerError
    case notImplemented
    case badGateway
    case serviceUnavailable
    case gatewayTimeout
    case httpVersionNotSupported
    case variantAlsoNegotiates
    case insufficientStorage
    case loopDetected
    case notExtended
    case networkAuthenticationRequired
    case unknown(Int)

    init(code: Int) {
        switch code {
        case 500: self = .internalServerError
        case 501: self = .notImplemented
        case 502: self = .badGateway
        case 503: self = .serviceUnavailable
        case 504: self = .gatewayTimeout
        case 505: self = .httpVersionNotSupported
        case 506: self = .variantAlsoNegotiates
        case 507: self = .insufficientStorage
        case 508: self = .loopDetected
        case 510: self = .notExtended
        case 511: self = .networkAuthenticationRequired
        default:  self = .unknown(code)
        }
    }

    var statusCode: Int {
        switch self {
        case .internalServerError:           return 500
        case .notImplemented:                return 501
        case .badGateway:                    return 502
        case .serviceUnavailable:            return 503
        case .gatewayTimeout:                return 504
        case .httpVersionNotSupported:       return 505
        case .variantAlsoNegotiates:         return 506
        case .insufficientStorage:           return 507
        case .loopDetected:                  return 508
        case .notExtended:                   return 510
        case .networkAuthenticationRequired: return 511
        case .unknown(let code):             return code
        }
    }

    var description: String {
        switch self {
        case .internalServerError:
            return "Internal Server Error"
        case .notImplemented:
            return "Not Implemented"
        case .badGateway:
            return "Bad Gateway"
        case .serviceUnavailable:
            return "Service Unavailable"
        case .gatewayTimeout:
            return "Gateway Timeout"
        case .httpVersionNotSupported:
            return "HTTP Version Not Supported"
        case .variantAlsoNegotiates:
            return "Variant Also Negotiates"
        case .insufficientStorage:
            return "Insufficient Storage"
        case .loopDetected:
            return "Loop Detected"
        case .notExtended:
            return "Not Extended"
        case .networkAuthenticationRequired:
            return "Network Authentication Required"
        case .unknown(let code):
            return "Unknown Server Error code: \(code)"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Orchestrator: Unifying the Network Layer

Now that we have defined our granular categories, we need a single source of truth to manage them. This is where the NetworkHTTPResponseService comes in. It acts as a “Master Enum” — an orchestrator that takes a raw HTTPURLResponse and transforms it into a strictly typed, categorized result.

By using Associated Values, we can nest our specific enums (like ClientErrorResponses) inside this service. This allows our network layer to remain clean: instead of checking dozens of status codes, it simply checks which "category" the response falls into.

/// The main orchestrator service that unifies all HTTP response categories.
/// It simplifies error handling by wrapping specific groups into associated values.
enum NetworkHTTPResponseService: Error, Equatable, HTTPResponseDescription {

    // MARK: - Equatable Implementation
    /// Compares two responses based on their numeric status codes.
    static func == (lhs: NetworkHTTPResponseService, rhs: NetworkHTTPResponseService) -> Bool {
        return lhs.statusCode == rhs.statusCode
    }

    // MARK: - Cases
    case informationResponse(InformationalResponse)
    case successfulResponse(SuccessfulResponses)
    case redirectionMessages(RedirectionMessages)
    case clientErrorResponses(ClientErrorResponses)
    case serverErrorResponses(ServerErrorResponses)
    case unknownError(_ status: Int)
    case badRequest(codeError: NSURLErrorCode) // Handles system-level URL errors

    // MARK: - Initializer
    /// Automatically categorizes the response based on the HTTP status code range.
    init(urlResponse: HTTPURLResponse) {
        let statusCode = urlResponse.statusCode
        switch statusCode {
        case 100..<199: 
            self = .informationResponse(InformationalResponse(code: statusCode))
        case 200..<299: 
            self = .successfulResponse(SuccessfulResponses(code: statusCode))
        case 300..<399: 
            self = .redirectionMessages(RedirectionMessages(code: statusCode))
        case 400..<499: 
            self = .clientErrorResponses(ClientErrorResponses(code: statusCode))
        case 500..<599: 
            self = .serverErrorResponses(ServerErrorResponses(code: statusCode))
        default:        
            self = .unknownError(statusCode)
        }
    }

    // MARK: - Convenience Getters
    /// Safely unwraps the successful status if the response was a success.
    var successfulStatus: SuccessfulResponses? {
        if case .successfulResponse(let status) = self { return status }
        return nil
    }

    /// Safely unwraps the client error if the request was malformed or unauthorized.
    var clientError: ClientErrorResponses? {
        if case .clientErrorResponses(let status) = self { return status }
        return nil
    }

    // MARK: - HTTPResponseDescription Conformance
    var statusCode: Int {
        switch self {
        case .informationResponse(let code):  return code.statusCode
        case .successfulResponse(let code):   return code.statusCode
        case .redirectionMessages(let code):  return code.statusCode
        case .clientErrorResponses(let code): return code.statusCode
        case .serverErrorResponses(let code): return code.statusCode
        case .unknownError(let code):         return code
        case .badRequest(let codeError):      return codeError.statusCode
        }
    }

    var description: String {
        switch self {
        case .informationResponse(let code):
            return "Informational: \(code.description)"
        case .successfulResponse(let code):
            return "Success: \(code.description)"
        case .redirectionMessages(let code):
            return "Redirection: \(code.description)"
        case .clientErrorResponses(let code):
            return "Client Error: \(code.description)"
        case .serverErrorResponses(let code):
            return "Server Error: \(code.description)"
        case .unknownError(let code):
            return "Unknown Status Code: \(code)"
        case .badRequest(let code):
            return "Bad System Request: \(code.description)"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Orchestrator: Unifying the Network Layer

Now that we have defined our granular categories, we need a single source of truth to manage them. This is where the NetworkHTTPResponseService comes in. It acts as a “Master Enum” — an orchestrator that takes a raw HTTPURLResponse and transforms it into a strictly typed, categorized result.

By using Associated Values, we can nest our specific enums (like ClientErrorResponses) inside this service. This allows our network layer to remain clean: instead of checking dozens of status codes, it simply checks which "category" the response falls into.

// The main orchestrator service that unifies all HTTP response categories.
/// It simplifies error handling by wrapping specific groups into associated values.
enum NetworkHTTPResponseService: Error, Equatable, HTTPResponseDescription {

    // MARK: - Equatable Implementation
    /// Compares two responses based on their numeric status codes.
    static func == (lhs: NetworkHTTPResponseService, rhs: NetworkHTTPResponseService) -> Bool {
        return lhs.statusCode == rhs.statusCode
    }

    // MARK: - Cases
    case informationResponse(InformationalResponse)
    case successfulResponse(SuccessfulResponses)
    case redirectionMessages(RedirectionMessages)
    case clientErrorResponses(ClientErrorResponses)
    case serverErrorResponses(ServerErrorResponses)
    case unknownError(_ status: Int)
    case badRequest(codeError: NSURLErrorCode) // Handles system-level URL errors

    // MARK: - Initializer
    /// Automatically categorizes the response based on the HTTP status code range.
    init(urlResponse: HTTPURLResponse) {
        let statusCode = urlResponse.statusCode
        switch statusCode {
        case 100..<199: 
            self = .informationResponse(InformationalResponse(code: statusCode))
        case 200..<299: 
            self = .successfulResponse(SuccessfulResponses(code: statusCode))
        case 300..<399: 
            self = .redirectionMessages(RedirectionMessages(code: statusCode))
        case 400..<499: 
            self = .clientErrorResponses(ClientErrorResponses(code: statusCode))
        case 500..<599: 
            self = .serverErrorResponses(ServerErrorResponses(code: statusCode))
        default:        
            self = .unknownError(statusCode)
        }
    }

    // MARK: - Convenience Getters
    /// Safely unwraps the successful status if the response was a success.
    var successfulStatus: SuccessfulResponses? {
        if case .successfulResponse(let status) = self { return status }
        return nil
    }

    /// Safely unwraps the client error if the request was malformed or unauthorized.
    var clientError: ClientErrorResponses? {
        if case .clientErrorResponses(let status) = self { return status }
        return nil
    }

    // MARK: - HTTPResponseDescription Conformance
    var statusCode: Int {
        switch self {
        case .informationResponse(let code):  return code.statusCode
        case .successfulResponse(let code):   return code.statusCode
        case .redirectionMessages(let code):  return code.statusCode
        case .clientErrorResponses(let code): return code.statusCode
        case .serverErrorResponses(let code): return code.statusCode
        case .unknownError(let code):         return code
        case .badRequest(let codeError):      return codeError.statusCode
        }
    }

    var description: String {
        switch self {
        case .informationResponse(let code):
            return "Informational: \(code.description)"
        case .successfulResponse(let code):
            return "Success: \(code.description)"
        case .redirectionMessages(let code):
            return "Redirection: \(code.description)"
        case .clientErrorResponses(let code):
            return "Client Error: \(code.description)"
        case .serverErrorResponses(let code):
            return "Server Error: \(code.description)"
        case .unknownError(let code):
            return "Unknown Status Code: \(code)"
        case .badRequest(let code):
            return "Bad System Request: \(code.description)"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Putting It All Together: The fetch Implementation

This is the final piece of the puzzle. The fetch function is where we apply all the architectural groundwork we've laid. It leverages Swift Concurrency (async/await) and the new Typed Throws feature introduced in Swift 6.0 to provide a compile-time guarantee that this function can only throw a NetworkHTTPResponseService error.

Implementation Details
The beauty of this method lies in its two-stage validation:

  1. Transport Level: We catch system-level URLError (like timeouts or lack of connection) and map them to our NSURLErrorCode.
  2. Protocol Level: Once we have an HTTPURLResponse, we use our orchestrator to decide if the status code represents success or a specific failure.
/// Fetches and decodes data from a given URL.
/// - Parameter url: The endpoint to request data from.
/// - Returns: A decoded object of type T.
/// - Throws: A `NetworkHTTPResponseService` error, providing specific details about the failure.
func fetch<T>(_ url: URL) async throws(NetworkHTTPResponseService) -> T where T : Decodable {
    let data: Data
    let response: URLResponse

    // Stage 1: Attempt the network transport
    do {
        (data, response) = try await urlSession.data(from: url)
    } catch let error as URLError {
        // Map low-level system errors to our structured NSURLErrorCode
        switch error.code {
        case .badURL:
            throw NetworkHTTPResponseService.badRequest(codeError: .badURL)
        case .timedOut:
            throw NetworkHTTPResponseService.badRequest(codeError: .timedOut)
        default:
            throw NetworkHTTPResponseService.badRequest(codeError: .unknown)
        }
    } catch {
        // Fallback for any other non-URLError exceptions
        throw NetworkHTTPResponseService.badRequest(codeError: .unknown)
    }

    // Stage 2: Validate the HTTP protocol response
    guard let httpResponse = response as? HTTPURLResponse else {
        throw NetworkHTTPResponseService.badRequest(codeError: .invalidResponse)
    }

    // Convert the status code into our categorized enum
    let responseStatus = NetworkHTTPResponseService(urlResponse: httpResponse)

    // Stage 3: Handle the categorized result
    switch responseStatus {
    case .successfulResponse:
        do {
            // Only attempt decoding if the server returned a 2xx status
            let result = try decoder.decode(T.self, from: data)
            return result
        } catch {
            // Wrap decoding failures as a specific badRequest subtype
            throw NetworkHTTPResponseService.badRequest(codeError: .decodingError)
        }
    default:
        // Automatically throw 1xx, 3xx, 4xx, or 5xx errors
        throw responseStatus
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways for Your Network Layer
Typed Throws (throws(NetworkHTTPResponseService)): By specifying the error type, we eliminate the need for the caller to cast a generic Error to our custom type. The compiler now knows exactly what to expect in the catch block.
Decoupled Decoding: Decoding only happens inside the .successfulResponse case. This prevents the app from trying to parse a JSON error body into a valid Data Model, which is a common source of "Silent Failures."
Readability: The switch responseStatus block is incredibly clean. It clearly separates the "Happy Path" from everything else, making the function easy to scan at a glance.
Final Conclusion
Building a professional network layer is not just about sending requests; it’s about managing expectations. By categorizing every possible outcome into a strict hierarchy of enums, we’ve transformed a fragile part of our app into a resilient, predictable service.

Your UI can now respond with surgical precision to a 401 Unauthorized or a 504 Gateway Timeout, significantly improving the user experience and making your code a joy to maintain.

Thank you so much for sticking with me until the very end!
I’ve put a lot of thought and effort into this implementation because I believe that clean, predictable code is the foundation of any great app. My goal was to provide you with a “production-ready” pattern that you can literally copy, paste, and adapt into your own projects today.

If this guide helped you rethink your error handling or saved you a few hours of debugging, I would truly appreciate your support.

Clap for this article to help others find it.
Follow me here on Medium for more deep dives into Swift, Clean Architecture, and iOS development.
Share your thoughts in the comments — I’d love to hear how you handle networking edge cases!

Happy coding, and let’s keep building better apps together! 🚀

full source code

my linkedin

Top comments (0)