Skip to content

Swift / SwiftUI — Networking

OWNER: martijn, valentin ALSO_USED_BY: alexander (design integration) LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: Swift 6.1 / URLSession (Foundation)


Overview

GE uses URLSession with async/await for all network communication. No Alamofire, no Moya — Foundation's networking is sufficient. This page covers the API client pattern, Codable, error handling, and retry logic.

API Client Architecture

Core Client

Every GE iOS project has a single APIClient that owns the URLSession and base configuration.

actor APIClient {
    private let session: URLSession
    private let baseURL: URL
    private let decoder: JSONDecoder
    private let encoder: JSONEncoder

    init(
        baseURL: URL,
        session: URLSession = .shared,
        decoder: JSONDecoder = .iso8601Decoder,
        encoder: JSONEncoder = .iso8601Encoder
    ) {
        self.baseURL = baseURL
        self.session = session
        self.decoder = decoder
        self.encoder = encoder
    }

    func get<T: Decodable>(_ path: String) async throws -> T {
        let request = try buildRequest(path: path, method: "GET")
        return try await execute(request)
    }

    func post<T: Decodable, B: Encodable>(
        _ path: String,
        body: B
    ) async throws -> T {
        var request = try buildRequest(path: path, method: "POST")
        request.httpBody = try encoder.encode(body)
        return try await execute(request)
    }

    func put<T: Decodable, B: Encodable>(
        _ path: String,
        body: B
    ) async throws -> T {
        var request = try buildRequest(path: path, method: "PUT")
        request.httpBody = try encoder.encode(body)
        return try await execute(request)
    }

    func delete(_ path: String) async throws {
        let request = try buildRequest(path: path, method: "DELETE")
        let (_, response) = try await session.data(for: request)
        try validateResponse(response)
    }
}

CHECK: Agent is making a network call. IF: The call uses URLSession.shared.data(for:) directly in a ViewModel. THEN: Route it through the APIClient. ViewModels never touch URLSession directly.

Request Building

extension APIClient {
    private func buildRequest(
        path: String,
        method: String
    ) throws -> URLRequest {
        guard let url = URL(string: path, relativeTo: baseURL) else {
            throw APIError.invalidURL(path)
        }
        var request = URLRequest(url: url)
        request.httpMethod = method
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        request.timeoutInterval = 30
        return request
    }

    private func execute<T: Decodable>(
        _ request: URLRequest
    ) async throws -> T {
        let (data, response) = try await session.data(for: request)
        try validateResponse(response)
        return try decoder.decode(T.self, from: data)
    }

    private func validateResponse(_ response: URLResponse) throws {
        guard let http = response as? HTTPURLResponse else {
            throw APIError.invalidResponse
        }
        switch http.statusCode {
        case 200...299:
            return
        case 401:
            throw APIError.unauthorized
        case 403:
            throw APIError.forbidden
        case 404:
            throw APIError.notFound
        case 429:
            throw APIError.rateLimited
        case 500...599:
            throw APIError.serverError(statusCode: http.statusCode)
        default:
            throw APIError.unexpectedStatus(http.statusCode)
        }
    }
}

Error Handling

API Error Enum

enum APIError: LocalizedError {
    case invalidURL(String)
    case invalidResponse
    case unauthorized
    case forbidden
    case notFound
    case rateLimited
    case serverError(statusCode: Int)
    case unexpectedStatus(Int)
    case decodingFailed(Error)
    case networkUnavailable

    var errorDescription: String? {
        switch self {
        case .invalidURL(let path):
            return "Invalid URL: \(path)"
        case .unauthorized:
            return "Session expired. Please log in again."
        case .rateLimited:
            return "Too many requests. Please wait a moment."
        case .serverError:
            return "Server error. Please try again later."
        case .networkUnavailable:
            return "No internet connection."
        default:
            return "An unexpected error occurred."
        }
    }
}

CHECK: Agent is handling an API error. IF: Error message exposes technical details (status codes, stack traces) to the user. THEN: Map to user-friendly messages via errorDescription. Technical details go to logs only.

Network Availability Check

import Network

@Observable
final class NetworkMonitor {
    var isConnected = true
    private let monitor = NWPathMonitor()
    private let queue = DispatchQueue(label: "NetworkMonitor")

    init() {
        monitor.pathUpdateHandler = { [weak self] path in
            Task { @MainActor in
                self?.isConnected = path.status == .satisfied
            }
        }
        monitor.start(queue: queue)
    }

    deinit {
        monitor.cancel()
    }
}

Codable Patterns

JSON Decoding Configuration

GE APIs return ISO 8601 dates and snake_case keys.

extension JSONDecoder {
    static let iso8601Decoder: JSONDecoder = {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .iso8601
        return decoder
    }()
}

extension JSONEncoder {
    static let iso8601Encoder: JSONEncoder = {
        let encoder = JSONEncoder()
        encoder.keyEncodingStrategy = .convertToSnakeCase
        encoder.dateEncodingStrategy = .iso8601
        return encoder
    }()
}

ANTI_PATTERN: Using manual CodingKeys for snake_case conversion. FIX: Use .convertFromSnakeCase / .convertToSnakeCase on the decoder/encoder.

ANTI_PATTERN: Using CodingKeys when the JSON key exactly matches the Swift property name. FIX: Only declare CodingKeys when you need to rename or exclude properties.

Response Wrapper

GE APIs wrap responses in an envelope.

struct APIResponse<T: Decodable>: Decodable {
    let data: T
    let meta: Meta?

    struct Meta: Decodable {
        let page: Int?
        let totalPages: Int?
        let totalCount: Int?
    }
}

Handling Optional and Partial Responses

struct Order: Codable, Identifiable {
    let id: UUID
    let title: String
    let status: OrderStatus
    let createdAt: Date
    let assignee: User?  // May be nil

    enum OrderStatus: String, Codable {
        case draft, pending, active, completed, cancelled
    }
}

CHECK: Agent is decoding a JSON response. IF: A field may be absent OR null in the JSON. THEN: Declare it as Optional (?). Do NOT use decodeIfPresent manually unless inside a custom init(from:).

Retry Logic

Configurable Retry

extension APIClient {
    func withRetry<T>(
        maxAttempts: Int = 3,
        delay: Duration = .seconds(1),
        shouldRetry: @Sendable (Error) -> Bool = { error in
            if let apiError = error as? APIError {
                switch apiError {
                case .serverError, .rateLimited:
                    return true
                default:
                    return false
                }
            }
            if let urlError = error as? URLError {
                return [.timedOut, .networkConnectionLost, .notConnectedToInternet]
                    .contains(urlError.code)
            }
            return false
        },
        operation: @Sendable () async throws -> T
    ) async throws -> T {
        var lastError: Error?
        for attempt in 1...maxAttempts {
            do {
                return try await operation()
            } catch {
                lastError = error
                guard attempt < maxAttempts, shouldRetry(error) else {
                    throw error
                }
                let backoff = delay * Double(attempt)
                try await Task.sleep(for: backoff)
            }
        }
        throw lastError ?? APIError.invalidResponse
    }
}

CHECK: Agent is adding retry logic. IF: Retry applies to non-idempotent requests (POST creating a resource). THEN: Do NOT retry. Only retry idempotent operations (GET, PUT, DELETE) or explicitly idempotent POST endpoints.

Usage

let orders: [Order] = try await apiClient.withRetry {
    try await apiClient.get("/orders")
}

Authentication

Token Injection via Interceptor

actor AuthInterceptor {
    private var accessToken: String?
    private let tokenProvider: TokenProviderProtocol

    func authenticate(_ request: inout URLRequest) async throws {
        if accessToken == nil || tokenProvider.isTokenExpired {
            accessToken = try await tokenProvider.refreshToken()
        }
        request.setValue(
            "Bearer \(accessToken ?? "")",
            forHTTPHeaderField: "Authorization"
        )
    }
}

CHECK: Agent is adding authentication to API calls. IF: Token is hardcoded or stored in UserDefaults unencrypted. THEN: Use the Keychain for token storage. Never persist tokens in plain text.

GE-Specific Conventions

  • No third-party HTTP libraries — URLSession + async/await is the standard.
  • All API clients are actors — Thread safety without manual locking.
  • Exponential backoff on retry — Delay doubles per attempt.
  • Timeout: 30s request, 60s resource — Matches server-side gateway timeouts.
  • Certificate pinning for production — Configured in Info.plist via App Transport Security.

Cross-References

READ_ALSO: wiki/docs/stack/swift-swiftui/index.md READ_ALSO: wiki/docs/stack/swift-swiftui/architecture.md READ_ALSO: wiki/docs/stack/swift-swiftui/pitfalls.md