Building an iOS app that talks to a language model means owning the failure modes of mobile networking. Robust ios network error handling llm api code separates a demo from a product that survives flaky coffee-shop Wi-Fi, carrier NAT timeouts, and provider rate limits.
This guide walks through a concrete Swift implementation. You will end up with a client that retries intelligently, decodes provider errors, streams safely, and degrades without crashing.
Step 1: Model the errors you actually get
Start by defining an error type that captures the distinct failure classes. A generic URLError is not enough; you need to distinguish canceled requests, transport failures, and LLM-specific API errors. Good ios network error handling llm api design begins at the type level so the rest of the app can switch on causes.
import Foundation
enum LLMAPIError: Error, LocalizedError {
case invalidURL
case transport(URLError)
case http(status: Int, payload: Data)
case decoding(Error)
case rateLimited(retryAfter: TimeInterval?)
case unknown(Error)
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid endpoint URL"
case .transport(let e): return "Transport: \(e.localizedDescription)"
case .http(let status, _): return "HTTP \(status)"
case .decoding: return "Failed to decode response"
case .rateLimited: return "Rate limited by provider"
case .unknown(let e): return "Unknown: \(e.localizedDescription)"
}
}
}
Map URLError codes to this enum at the boundary. That keeps the rest of your app free of Foundation specifics and lets you write UI logic like “if rateLimited, show upgrade prompt” without sniffing raw error domains.
Step 2: Configure a URLSession that fails fast
Mobile networks lie. A request can hang for minutes if you accept default timeouts. Set explicit intervals and disable background waits. Solid ios network error handling llm api behavior treats timeouts as a first-class signal, not an afterthought.
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 20
config.timeoutIntervalForResource = 60
config.waitsForConnectivity = false
config.httpMaximumConnectionsPerHost = 4
let session = URLSession(configuration: config)
A 20-second request timeout forces a failure you can handle. Pair this with a Task cancellation policy so the user can bail out of a generation. If you use URLSession.shared, you inherit its 60-second timeout and connection pooling that may not suit LLM streaming—don’t.
Step 3: Wrap requests in a retry operator
LLM endpoints throttle and drop connections. Retry idempotent POSTs (chat completions without side effects) with exponential backoff and jitter. Never retry on 400 or 401—those are client errors. The retry loop is the core of ios network error handling llm api resilience.
func requestWithRetry<T: Decodable>(
_ urlRequest: URLRequest,
decodeTo type: T.Type,
maxAttempts: Int = 3
) async throws -> T {
var attempt = 0
while true {
do {
let (data, response) = try await session.data(for: urlRequest)
guard let http = response as? HTTPURLResponse else {
throw LLMAPIError.unknown(NSError(domain: "no-http", code: 0))
}
if (200..<300).contains(http.statusCode) {
return try JSONDecoder().decode(T.self, from: data)
}
if http.statusCode == 429 {
let retryAfter = http.retryAfterValue
throw LLMAPIError.rateLimited(retryAfter: retryAfter)
}
throw LLMAPIError.http(status: http.statusCode, payload: data)
} catch {
attempt += 1
if attempt >= maxAttempts { throw mapError(error) }
let base = Double(attempt) * 0.5
let jitter = Double.random(in: 0..<0.3)
try await Task.sleep(nanoseconds: UInt64((base + jitter) * 1_000_000_000))
}
}
}
This loop catches transport errors, non-2xx, and decode errors. It stops after maxAttempts and surfaces a typed error. Cancellation propagates because Task.sleep throws CancellationError when the parent task is canceled—do not swallow that.
Step 4: Parse provider error bodies without crashing
Providers return structured JSON on failure. Decode it separately from the success model. Parsing errors is core to ios network error handling llm api robustness because a 500 from a gateway often carries a useful type field.
struct ProviderError: Decodable {
let error: Detail
struct Detail: Decodable {
let message: String
let type: String?
}
}
extension HTTPURLResponse {
var retryAfterValue: TimeInterval? {
guard let raw = value(forHTTPHeaderField: "Retry-After") else { return nil }
return TimeInterval(raw)
}
}
When you catch LLMAPIError.http, inspect the payload:
if case .http(let status, let data) = error {
if let perr = try? JSONDecoder().decode(ProviderError.self, from: data) {
logger.warning("Provider \(status): \(perr.error.message)")
}
}
Do not assume the body is valid JSON. Some gateways return HTML on 502. Catch the decode failure and log the raw bytes at debug level only—never leak full payloads to production crash reporters.
Step 5: Handle streaming interruptions
Streaming token output is the norm for chat UIs. Use URLSession.bytes(for:) and break on throw. Mid-stream drops are where naive ios network error handling llm api code falls apart: the user sees a frozen spinner.
func streamCompletion(request: URLRequest) -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
let (bytes, response) = try await session.bytes(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw LLMAPIError.unknown(NSError(domain: "bad-stream", code: 0))
}
for try await line in bytes.lines {
if line.hasPrefix("data:") {
let json = line.dropFirst(5)
if json == "[DONE]" { continuation.finish(); return }
continuation.yield(String(json))
}
}
continuation.finish()
} catch {
continuation.finish(throwing: mapError(error))
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
If the network drops mid-stream, the bytes.lines loop throws and you finalize the stream with an error. Your UI should render the partial transcript and offer a retry. SSE lines can also arrive fragmented; bytes.lines handles line buffering, but you must still guard against malformed JSON delta chunks.
Step 6: Degrade gracefully when the model is unreachable
When retries exhaust, you still owe the user a response. Cache the last successful completion for the query pattern, or fall back to a smaller on-device model. If you route through a gateway like n4n.ai, the server may already perform automatic fallback when a provider is rate-limited or degraded, but your client must still handle the case where the gateway itself returns an error or the network drops.
Implement a fallback chain at the call site:
func generate(prompt: String) async throws -> String {
do {
return try await primaryCall(prompt)
} catch LLMAPIError.rateLimited(let retry) {
if let retry, retry < 5 { try await Task.sleep(nanoseconds: UInt64(retry*1e9)) }
return try await generate(prompt)
} catch {
if let cached = cache.get(prompt) { return cached }
throw error
}
}
Keep the fallback logic above the networking layer. The session should only know about transports and status codes; your feature code decides what “good enough” means when the cloud model is dark.
Verify your implementation
You cannot claim ios network error handling llm api works until you break it on purpose.
- Unit test with a mocked session. Subclass
URLProtocolto return 500, then 200. Assert the retry loop succeeds on the second attempt and that no error escapes. - Use Network Link Conditioner (available in Xcode Additional Tools) to simulate 100% packet loss. Confirm the request fails within your 20s timeout and surfaces
LLMAPIError.transport. - Kill the server mid-stream. Run the streaming call against a local mock that closes the socket after 10 lines. Verify the
AsyncThrowingStreamterminates with an error and the UI shows partial text. - Force a 429 with a
Retry-After: 1header. Check that the client waits ~1s before retrying, not the default backoff.
If all four pass, you have a client that behaves in production. The patterns above are minimal but cover the 90% case for shipping an iOS LLM feature.