Swift Network Error Handling: A Complete Guide to a Production-Ready Layer

Written by unspected13 | Published 2026/02/17
Tech Story Tags: swift | swift-programming | ios-app-development | swift-6 | swift-network-error-handling | ios-networking | typed-throws | network-layer-architecture

TLDRIn a previous article, we explored how to construct a robust, abstract network layer using**Clean Architecture**. In this article, I explain how to categorize HTTP status codes into specific enums. This approach ensures that our logic is both type-safe and highly readable.via the TL;DR App

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 }
}

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."  
      } 
    }
}

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)" 
        } 
      } 
    }

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)" 
        } 
    }
  }

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)" 
       } 
     }
    }

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)" 
          } 
        }
    }

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)" 
      }
   }
}

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)" 
        } 
    }
}

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 { 
          let (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 
                   }
              }

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 here


Written by unspected13 | Senior iOS Developer with 5 + years of experience
Published by HackerNoon on 2026/02/17