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
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:
- Transport Level: We catch system-level
URLError(like timeouts or lack of connection) and map them to ourNSURLErrorCode. - 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 genericErrorto our custom type. The compiler now knows exactly what to expect in thecatchblock. - Decoupled Decoding: Decoding only happens inside the
.successfulResponsecase. 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 responseStatusblock 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! 🚀