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¶
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.plistvia 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