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. previous article Clean Architecture 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. MDN Web Docs MDN Web Docs 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. HTTPResponseDescription status code 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 } } 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. NSURLErrorCode HTTPResponseDescription 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." } } } 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. Informational Responses /// 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)" } } } /// 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. 2xx category 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. 200 OK 201 Created 204 No Content /// 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)" } } } /// 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. 3xx category URLSession 304 Not Modified 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)" } } } // 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. 4xx category 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. 401 Unauthorized 429 Too Many Requests /// 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)" } } } /// 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. 5xx category 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. 503 Service Unavailable 504 Gateway Timeout /// 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)" } } } /// 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. NetworkHTTPResponseService HTTPURLResponse 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. Associated Values ClientErrorResponses /// 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)" } } } /// 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 fetch 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. fetch Swift Concurrency (async/await) Typed Throws NetworkHTTPResponseService Implementation Details The beauty of this method lies in its two-stage validation: Transport Level: We catch system-level URLError (like timeouts or lack of connection) and map them to our NSURLErrorCode. Protocol Level: Once we have an HTTPURLResponse, we use our orchestrator to decide if the status code represents success or a specific failure. Transport Level: We catch system-level URLError (like timeouts or lack of connection) and map them to our NSURLErrorCode. Transport Level URLError NSURLErrorCode Protocol Level: Once we have an HTTPURLResponse, we use our orchestrator to decide if the status code represents success or a specific failure. Protocol Level HTTPURLResponse /// 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 } } /// 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. 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. Typed Throws ( throws(NetworkHTTPResponseService) ) Error catch 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." Decoupled Decoding .successfulResponse 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. Readability switch responseStatus 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. managing expectations 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. 401 Unauthorized 504 Gateway Timeout 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 Full source code here