All skills
dpearson2699 avatar

/ios-networking

@cf3fe87

Build, review, or improve networking code in iOS/macOS apps using URLSession with async/await, structured concurrency, and modern Swift patterns. Use when working with REST APIs, downloading files, uploading data, WebSocket connections, pagination, retry logic, request middleware, caching, background transfers, or network reachability monitoring. Also use when handling HTTP requests, API clients, network error handling, or data fetching in Swift apps.

Use this Skill: https://skilld.dev/gh/dpearson2699/swift-ios-skills/ios-networking

This session only. Nothing lands on disk.

referencesurlsession-patterns.md

≈8k tokens on demand. Your agent reads this file only when SKILL.md points to it.

URLSession Patterns Reference

Complete implementation patterns for URLSession-based networking.


Contents

Complete API Client with Protocol

A full-featured client with middleware support, configurable decoding, and response validation.

Protocol

protocol APIClientProtocol: Sendable {
    func request<T: Decodable & Sendable>(
        _ type: T.Type,
        endpoint: Endpoint
    ) async throws -> T

    func request(endpoint: Endpoint) async throws

    func upload<T: Decodable & Sendable>(
        _ type: T.Type,
        endpoint: Endpoint,
        body: Data
    ) async throws -> T
}

Endpoint Definition

struct Endpoint: Sendable {
    let path: String
    var method: HTTPMethod = .get
    var queryItems: [URLQueryItem] = []
    var headers: [String: String] = [:]
    var body: Data? = nil
    var cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy
    var timeoutInterval: TimeInterval = 30

    enum HTTPMethod: String, Sendable {
        case get = "GET"
        case post = "POST"
        case put = "PUT"
        case patch = "PATCH"
        case delete = "DELETE"
    }

    func urlRequest(relativeTo baseURL: URL) -> URLRequest {
        var components = URLComponents(
            url: baseURL.appendingPathComponent(path),
            resolvingAgainstBaseURL: true
        )!
        if !queryItems.isEmpty {
            components.queryItems = queryItems
        }
        var request = URLRequest(url: components.url!)
        request.httpMethod = method.rawValue
        request.httpBody = body
        request.cachePolicy = cachePolicy
        request.timeoutInterval = timeoutInterval
        for (key, value) in headers {
            request.setValue(value, forHTTPHeaderField: key)
        }
        return request
    }
}

Client Implementation

final class APIClient: APIClientProtocol {
    private let baseURL: URL
    private let session: URLSession
    private let decoder: JSONDecoder
    private let encoder: JSONEncoder
    private let middlewares: [any RequestMiddleware]

    init(
        baseURL: URL,
        session: URLSession = .shared,
        decoder: JSONDecoder = {
            let d = JSONDecoder()
            d.dateDecodingStrategy = .iso8601
            d.keyDecodingStrategy = .convertFromSnakeCase
            return d
        }(),
        encoder: JSONEncoder = {
            let e = JSONEncoder()
            e.dateEncodingStrategy = .iso8601
            e.keyEncodingStrategy = .convertToSnakeCase
            return e
        }(),
        middlewares: [any RequestMiddleware] = []
    ) {
        self.baseURL = baseURL
        self.session = session
        self.decoder = decoder
        self.encoder = encoder
        self.middlewares = middlewares
    }

    func request<T: Decodable & Sendable>(
        _ type: T.Type,
        endpoint: Endpoint
    ) async throws -> T {
        let request = try await prepareRequest(for: endpoint)
        let (data, response) = try await session.data(for: request)
        try validateResponse(response, data: data)
        return try decoder.decode(T.self, from: data)
    }

    func request(endpoint: Endpoint) async throws {
        let request = try await prepareRequest(for: endpoint)
        let (data, response) = try await session.data(for: request)
        try validateResponse(response, data: data)
    }

    func upload<T: Decodable & Sendable>(
        _ type: T.Type,
        endpoint: Endpoint,
        body: Data
    ) async throws -> T {
        var request = try await prepareRequest(for: endpoint)
        request.httpBody = body
        let (data, response) = try await session.upload(for: request, from: body)
        try validateResponse(response, data: data)
        return try decoder.decode(T.self, from: data)
    }

    // MARK: - Convenience methods

    func get<T: Decodable & Sendable>(
        _ type: T.Type,
        path: String,
        queryItems: [URLQueryItem] = []
    ) async throws -> T {
        try await request(type, endpoint: Endpoint(
            path: path,
            method: .get,
            queryItems: queryItems
        ))
    }

    func post<T: Decodable & Sendable, B: Encodable & Sendable>(
        _ type: T.Type,
        path: String,
        body: B
    ) async throws -> T {
        let bodyData = try encoder.encode(body)
        return try await request(type, endpoint: Endpoint(
            path: path,
            method: .post,
            headers: ["Content-Type": "application/json"],
            body: bodyData
        ))
    }

    func delete(path: String) async throws {
        try await request(endpoint: Endpoint(path: path, method: .delete))
    }

    // MARK: - Internal

    private func prepareRequest(for endpoint: Endpoint) async throws -> URLRequest {
        var request = endpoint.urlRequest(relativeTo: baseURL)
        for middleware in middlewares {
            request = try await middleware.prepare(request)
        }
        return request
    }

    private func validateResponse(_ response: URLResponse, data: Data) throws {
        guard let http = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }
        guard (200..<300).contains(http.statusCode) else {
            let apiError = try? decoder.decode(APIErrorBody.self, from: data)
            throw NetworkError.httpError(
                statusCode: http.statusCode,
                data: data,
                message: apiError?.message
            )
        }
    }
}

Error Types

enum NetworkError: Error, Sendable, LocalizedError {
    case invalidResponse
    case httpError(statusCode: Int, data: Data, message: String? = nil)
    case noConnection
    case timedOut
    case cancelled

    var errorDescription: String? {
        switch self {
        case .invalidResponse:
            return "Invalid server response"
        case .httpError(let code, _, let message):
            return message ?? "HTTP error \(code)"
        case .noConnection:
            return "No internet connection"
        case .timedOut:
            return "Request timed out"
        case .cancelled:
            return nil
        }
    }

    static func from(_ urlError: URLError) -> NetworkError {
        switch urlError.code {
        case .notConnectedToInternet, .networkConnectionLost:
            return .noConnection
        case .timedOut:
            return .timedOut
        case .cancelled:
            return .cancelled
        default:
            return .invalidResponse
        }
    }
}

struct APIErrorBody: Decodable, Sendable {
    let code: String?
    let message: String?
}

Request Middleware

protocol RequestMiddleware: Sendable {
    func prepare(_ request: URLRequest) async throws -> URLRequest
}

struct AuthMiddleware: RequestMiddleware {
    let tokenProvider: @Sendable () async throws -> String

    func prepare(_ request: URLRequest) async throws -> URLRequest {
        var request = request
        let token = try await tokenProvider()
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        return request
    }
}

Request Builder Pattern

For complex request construction, a builder provides a fluent API that reduces errors.

struct RequestBuilder: Sendable {
    private var method: String = "GET"
    private var path: String
    private var baseURL: URL
    private var queryItems: [URLQueryItem] = []
    private var headers: [String: String] = [:]
    private var body: Data?
    private var cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy
    private var timeout: TimeInterval = 30

    init(baseURL: URL, path: String) {
        self.baseURL = baseURL
        self.path = path
    }

    func method(_ method: String) -> RequestBuilder {
        var copy = self
        copy.method = method
        return copy
    }

    func query(_ name: String, _ value: String?) -> RequestBuilder {
        guard let value else { return self }
        var copy = self
        copy.queryItems.append(URLQueryItem(name: name, value: value))
        return copy
    }

    func header(_ name: String, _ value: String) -> RequestBuilder {
        var copy = self
        copy.headers[name] = value
        return copy
    }

    func jsonBody<T: Encodable>(_ value: T) throws -> RequestBuilder {
        var copy = self
        copy.body = try JSONEncoder().encode(value)
        copy.headers["Content-Type"] = "application/json"
        return copy
    }

    func timeout(_ interval: TimeInterval) -> RequestBuilder {
        var copy = self
        copy.timeout = interval
        return copy
    }

    func cachePolicy(_ policy: URLRequest.CachePolicy) -> RequestBuilder {
        var copy = self
        copy.cachePolicy = policy
        return copy
    }

    func build() -> URLRequest {
        var components = URLComponents(
            url: baseURL.appendingPathComponent(path),
            resolvingAgainstBaseURL: true
        )!
        if !queryItems.isEmpty {
            components.queryItems = queryItems
        }
        var request = URLRequest(url: components.url!)
        request.httpMethod = method
        request.httpBody = body
        request.cachePolicy = cachePolicy
        request.timeoutInterval = timeout
        for (key, value) in headers {
            request.setValue(value, forHTTPHeaderField: key)
        }
        return request
    }
}

// Usage
let request = try RequestBuilder(baseURL: apiURL, path: "users")
    .method("POST")
    .header("X-Request-ID", UUID().uuidString)
    .jsonBody(CreateUserRequest(name: "Alice", email: "alice@example.com"))
    .timeout(15)
    .build()

Multipart Form Upload

Multipart/form-data uploads are common for file attachments. Build the body manually -- no third-party library needed.

struct MultipartFormData: Sendable {
    private let boundary: String
    private var parts: [Part] = []

    init(boundary: String = UUID().uuidString) {
        self.boundary = boundary
    }

    var contentType: String {
        "multipart/form-data; boundary=\(boundary)"
    }

    mutating func addField(name: String, value: String) {
        parts.append(Part(
            headers: "Content-Disposition: form-data; name=\"\(name)\"",
            body: Data(value.utf8)
        ))
    }

    mutating func addFile(
        name: String,
        filename: String,
        mimeType: String,
        data: Data
    ) {
        parts.append(Part(
            headers: """
            Content-Disposition: form-data; name="\(name)"; filename="\(filename)"\r
            Content-Type: \(mimeType)
            """,
            body: data
        ))
    }

    func encode() -> Data {
        var data = Data()
        let crlf = "\r\n"
        for part in parts {
            data.append("--\(boundary)\(crlf)")
            data.append("\(part.headers)\(crlf)\(crlf)")
            data.append(part.body)
            data.append(crlf)
        }
        data.append("--\(boundary)--\(crlf)")
        return data
    }

    private struct Part: Sendable {
        let headers: String
        let body: Data
    }
}

extension Data {
    mutating func append(_ string: String) {
        append(Data(string.utf8))
    }
}

// Usage
var form = MultipartFormData()
form.addField(name: "title", value: "Profile Photo")
form.addFile(
    name: "image",
    filename: "photo.jpg",
    mimeType: "image/jpeg",
    data: imageData
)

var request = URLRequest(url: uploadURL)
request.httpMethod = "POST"
request.setValue(form.contentType, forHTTPHeaderField: "Content-Type")
request.httpBody = form.encode()

let (data, response) = try await URLSession.shared.upload(
    for: request,
    from: form.encode()
)

Download with Progress Tracking

Use bytes(for:) for real-time progress. The response includes expectedContentLength for calculating percentage.

@available(iOS 15.0, *)
func downloadWithProgress(
    from url: URL,
    progressHandler: @Sendable (Double) -> Void
) async throws -> Data {
    let (bytes, response) = try await URLSession.shared.bytes(from: url)

    let expectedLength = response.expectedContentLength
    var receivedData = Data()
    if expectedLength > 0 {
        receivedData.reserveCapacity(Int(expectedLength))
    }

    var receivedLength: Int64 = 0
    for try await byte in bytes {
        receivedData.append(byte)
        receivedLength += 1
        if expectedLength > 0 {
            let progress = Double(receivedLength) / Double(expectedLength)
            progressHandler(progress)
        }
    }

    return receivedData
}

For large files, prefer URLSessionDownloadTask with a delegate for better memory efficiency and background support.

Download to File with Progress (Delegate-Based)

@available(iOS 15.0, *)
final class DownloadManager: NSObject, URLSessionDownloadDelegate, Sendable {
    private let continuation: AsyncStream<DownloadEvent>.Continuation

    enum DownloadEvent: Sendable {
        case progress(Double)
        case completed(URL)
        case failed(Error)
    }

    static func download(from url: URL) -> AsyncStream<DownloadEvent> {
        AsyncStream { continuation in
            let manager = DownloadManager(continuation: continuation)
            let session = URLSession(
                configuration: .default,
                delegate: manager,
                delegateQueue: nil
            )
            session.downloadTask(with: url).resume()
        }
    }

    private init(continuation: AsyncStream<DownloadEvent>.Continuation) {
        self.continuation = continuation
    }

    nonisolated func urlSession(
        _ session: URLSession,
        downloadTask: URLSessionDownloadTask,
        didFinishDownloadingTo location: URL
    ) {
        // Move file to permanent location before this method returns
        let destination = FileManager.default.temporaryDirectory
            .appendingPathComponent(UUID().uuidString)
        do {
            try FileManager.default.moveItem(at: location, to: destination)
            continuation.yield(.completed(destination))
        } catch {
            continuation.yield(.failed(error))
        }
        continuation.finish()
    }

    nonisolated func urlSession(
        _ session: URLSession,
        downloadTask: URLSessionDownloadTask,
        didWriteData bytesWritten: Int64,
        totalBytesWritten: Int64,
        totalBytesExpectedToWrite: Int64
    ) {
        guard totalBytesExpectedToWrite > 0 else { return }
        let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
        continuation.yield(.progress(progress))
    }

    nonisolated func urlSession(
        _ session: URLSession,
        task: URLSessionTask,
        didCompleteWithError error: (any Error)?
    ) {
        if let error {
            continuation.yield(.failed(error))
            continuation.finish()
        }
    }
}

Cursor-Based Pagination

A reusable paginator that conforms to AsyncSequence, yielding pages of results until the server indicates no more data.

struct PageResponse<T: Decodable & Sendable>: Decodable, Sendable {
    let data: [T]
    let pagination: PaginationInfo
}

struct PaginationInfo: Decodable, Sendable {
    let nextCursor: String?
    let hasMore: Bool
}

struct CursorPaginator<T: Decodable & Sendable>: AsyncSequence {
    typealias Element = [T]

    private let fetchPage: @Sendable (String?) async throws -> PageResponse<T>

    init(fetchPage: @escaping @Sendable (String?) async throws -> PageResponse<T>) {
        self.fetchPage = fetchPage
    }

    func makeAsyncIterator() -> Iterator {
        Iterator(fetchPage: fetchPage)
    }

    struct Iterator: AsyncIteratorProtocol {
        private let fetchPage: @Sendable (String?) async throws -> PageResponse<T>
        private var cursor: String?
        private var exhausted = false

        init(fetchPage: @escaping @Sendable (String?) async throws -> PageResponse<T>) {
            self.fetchPage = fetchPage
        }

        mutating func next() async throws -> [T]? {
            guard !exhausted else { return nil }
            try Task.checkCancellation()

            let response = try await fetchPage(cursor)
            cursor = response.pagination.nextCursor
            exhausted = !response.pagination.hasMore

            return response.data.isEmpty ? nil : response.data
        }
    }
}

// Usage
let paginator = CursorPaginator<User> { cursor in
    var queryItems = [URLQueryItem(name: "limit", value: "50")]
    if let cursor {
        queryItems.append(URLQueryItem(name: "cursor", value: cursor))
    }
    return try await client.get(
        PageResponse<User>.self,
        path: "users",
        queryItems: queryItems
    )
}

var allUsers: [User] = []
for try await batch in paginator {
    allUsers.append(contentsOf: batch)
}

Offset-Based Pagination

struct OffsetPaginator<T: Decodable & Sendable>: AsyncSequence {
    typealias Element = [T]

    private let pageSize: Int
    private let fetchPage: @Sendable (Int, Int) async throws -> [T]

    init(
        pageSize: Int = 20,
        fetchPage: @escaping @Sendable (_ offset: Int, _ limit: Int) async throws -> [T]
    ) {
        self.pageSize = pageSize
        self.fetchPage = fetchPage
    }

    func makeAsyncIterator() -> Iterator {
        Iterator(pageSize: pageSize, fetchPage: fetchPage)
    }

    struct Iterator: AsyncIteratorProtocol {
        private let pageSize: Int
        private let fetchPage: @Sendable (Int, Int) async throws -> [T]
        private var offset = 0
        private var exhausted = false

        init(
            pageSize: Int,
            fetchPage: @escaping @Sendable (Int, Int) async throws -> [T]
        ) {
            self.pageSize = pageSize
            self.fetchPage = fetchPage
        }

        mutating func next() async throws -> [T]? {
            guard !exhausted else { return nil }
            try Task.checkCancellation()

            let items = try await fetchPage(offset, pageSize)
            offset += items.count
            if items.count < pageSize { exhausted = true }

            return items.isEmpty ? nil : items
        }
    }
}

URLProtocol Mock for Testing

URLProtocol is the correct way to mock network responses at the transport level. It works with any URLSession configuration and does not require changing production code.

final class MockURLProtocol: URLProtocol {
    nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?

    override class func canInit(with request: URLRequest) -> Bool { true }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

    override func startLoading() {
        guard let handler = Self.requestHandler else {
            fatalError("MockURLProtocol.requestHandler is not set")
        }

        do {
            let (response, data) = try handler(request)
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocolDidFinishLoading(self)
        } catch {
            client?.urlProtocol(self, didFailWithError: error)
        }
    }

    override func stopLoading() {}
}

Test Setup

import Testing

@Suite struct APIClientTests {
    let client: APIClient
    let session: URLSession

    init() {
        let config = URLSessionConfiguration.ephemeral
        config.protocolClasses = [MockURLProtocol.self]
        session = URLSession(configuration: config)
        client = APIClient(
            baseURL: URL(string: "https://api.example.com")!,
            session: session
        )
    }

    @Test func fetchUsersDecodesCorrectly() async throws {
        let usersJSON = """
        [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
        """
        MockURLProtocol.requestHandler = { request in
            #expect(request.url?.path == "/users")
            let response = HTTPURLResponse(
                url: request.url!,
                statusCode: 200,
                httpVersion: nil,
                headerFields: ["Content-Type": "application/json"]
            )!
            return (response, Data(usersJSON.utf8))
        }

        let users: [User] = try await client.get([User].self, path: "users")
        #expect(users.count == 2)
        #expect(users[0].name == "Alice")
    }

    @Test func fetchReturnsHTTPError() async throws {
        MockURLProtocol.requestHandler = { request in
            let response = HTTPURLResponse(
                url: request.url!,
                statusCode: 404,
                httpVersion: nil,
                headerFields: nil
            )!
            return (response, Data())
        }

        await #expect(throws: NetworkError.self) {
            let _: [User] = try await client.get([User].self, path: "missing")
        }
    }

    @Test func requestIncludesAuthHeader() async throws {
        let authClient = APIClient(
            baseURL: URL(string: "https://api.example.com")!,
            session: session,
            middlewares: [AuthMiddleware { "test-token" }]
        )

        MockURLProtocol.requestHandler = { request in
            #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token")
            let response = HTTPURLResponse(
                url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil
            )!
            return (response, Data("{}".utf8))
        }

        let _: EmptyResponse = try await authClient.get(EmptyResponse.self, path: "me")
    }
}

struct EmptyResponse: Decodable, Sendable {}

Retry with Exponential Backoff

Respect cancellation. Do not retry client errors (4xx except 429 rate limiting). Include jitter to prevent thundering herd.

func withRetry<T: Sendable>(
    maxAttempts: Int = 3,
    initialDelay: Duration = .seconds(1),
    maxDelay: Duration = .seconds(30),
    shouldRetry: @Sendable (Error) -> Bool = { error in
        if error is CancellationError { return false }
        if case NetworkError.httpError(let code, _, _) = error {
            return code >= 500 || code == 429
        }
        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 0..<maxAttempts {
        try Task.checkCancellation()
        do {
            return try await operation()
        } catch {
            lastError = error
            guard shouldRetry(error), attempt < maxAttempts - 1 else {
                throw error
            }
            // Exponential backoff with jitter
            let base = Double(initialDelay.components.seconds) * pow(2.0, Double(attempt))
            let capped = min(base, Double(maxDelay.components.seconds))
            let jitter = Double.random(in: 0...(capped * 0.1))
            let delay = Duration.seconds(capped + jitter)
            try await Task.sleep(for: delay)
        }
    }

    throw lastError!
}

// Usage
let users = try await withRetry {
    try await client.get([User].self, path: "users")
}

Certificate Pinning (URLSessionDelegate)

Prefer ATS NSPinnedDomains for declarative certificate pinning when the pinset can ship in Info.plist. For manual URLSessionDelegate trust work, defer to the swift-security skill: correct SPKI pinning requires hashing the Subject Public Key Info structure, not just the raw key bytes returned by SecKeyCopyExternalRepresentation.

Important considerations:

  • Pin at least two keys (primary + backup) to avoid lockout during rotation.
  • Have a remote kill switch (feature flag) to disable pinning in emergencies.
  • Test certificate rotation in staging before deploying to production.
  • Always evaluate system trust before applying pins.
  • Keep certificate-trust implementation details in the security boundary.

Request Logging / Debugging Middleware

Log outgoing requests and incoming responses for debugging. Disable or reduce verbosity in release builds.

struct LoggingMiddleware: RequestMiddleware {
    let logger: Logger

    func prepare(_ request: URLRequest) async throws -> URLRequest {
        #if DEBUG
        let method = request.httpMethod ?? "GET"
        let url = request.url?.absoluteString ?? "unknown"
        logger.debug("[\(method)] \(url)")
        if let headers = request.allHTTPHeaderFields {
            for (key, value) in headers where key != "Authorization" {
                logger.debug("  \(key): \(value)")
            }
        }
        if let body = request.httpBody, body.count < 10_000 {
            logger.debug("  Body: \(String(data: body, encoding: .utf8) ?? "<binary>")")
        }
        #endif
        return request
    }
}

Response Logging

To log responses, wrap the transport call rather than using middleware:

func loggedRequest<T: Decodable & Sendable>(
    _ type: T.Type,
    endpoint: Endpoint,
    logger: Logger
) async throws -> T {
    let start = ContinuousClock().now
    do {
        let result: T = try await request(type, endpoint: endpoint)
        let elapsed = ContinuousClock().now - start
        logger.debug("[\(endpoint.method.rawValue)] \(endpoint.path) -> 200 (\(elapsed))")
        return result
    } catch {
        let elapsed = ContinuousClock().now - start
        logger.error("[\(endpoint.method.rawValue)] \(endpoint.path) -> ERROR (\(elapsed)): \(error)")
        throw error
    }
}

Request Caching Strategies

URLCache Configuration

// 50 MB memory / 200 MB disk cache
let cache = URLCache(
    memoryCapacity: 50 * 1024 * 1024,
    diskCapacity: 200 * 1024 * 1024,
    directory: FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
        .first?.appendingPathComponent("URLCache")
)

let config = URLSessionConfiguration.default
config.urlCache = cache
config.requestCachePolicy = .returnCacheDataElseLoad

let session = URLSession(configuration: config)

Per-Request Cache Control

// Force fresh data
var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalCacheData

// Use cached if available
request.cachePolicy = .returnCacheDataElseLoad

// Cache only (offline mode)
request.cachePolicy = .returnCacheDataDontLoad

ETag / If-None-Match

func fetchWithETag<T: Decodable & Sendable>(
    _ type: T.Type,
    url: URL,
    cachedETag: String?,
    cachedData: Data?
) async throws -> (T, String?) {
    var request = URLRequest(url: url)
    if let etag = cachedETag {
        request.setValue(etag, forHTTPHeaderField: "If-None-Match")
    }

    let (data, response) = try await URLSession.shared.data(for: request)
    guard let http = response as? HTTPURLResponse else {
        throw NetworkError.invalidResponse
    }

    if http.statusCode == 304, let cachedData {
        // Not modified -- use cached data
        let decoded = try JSONDecoder().decode(T.self, from: cachedData)
        return (decoded, cachedETag)
    }

    let newETag = http.value(forHTTPHeaderField: "ETag")
    let decoded = try JSONDecoder().decode(T.self, from: data)
    return (decoded, newETag)
}

Server-Sent Events (SSE) Parsing

Use bytes(for:) to consume a streaming SSE endpoint.

struct ServerSentEvent: Sendable {
    var event: String?
    var data: String
    var id: String?
}

func sseStream(from url: URL) -> AsyncThrowingStream<ServerSentEvent, Error> {
    AsyncThrowingStream { continuation in
        let task = Task {
            do {
                var request = URLRequest(url: url)
                request.setValue("text/event-stream", forHTTPHeaderField: "Accept")

                let (bytes, _) = try await URLSession.shared.bytes(for: request)

                var currentEvent: String?
                var currentData = ""
                var currentId: String?

                for try await line in bytes.lines {
                    if line.isEmpty {
                        // Empty line = dispatch event
                        if !currentData.isEmpty {
                            continuation.yield(ServerSentEvent(
                                event: currentEvent,
                                data: currentData.trimmingCharacters(in: .newlines),
                                id: currentId
                            ))
                        }
                        currentEvent = nil
                        currentData = ""
                        currentId = nil
                    } else if line.hasPrefix("event:") {
                        currentEvent = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces)
                    } else if line.hasPrefix("data:") {
                        let value = String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces)
                        currentData += currentData.isEmpty ? value : "\n" + value
                    } else if line.hasPrefix("id:") {
                        currentId = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
                    }
                }
                continuation.finish()
            } catch {
                continuation.finish(throwing: error)
            }
        }
        continuation.onTermination = { _ in task.cancel() }
    }
}

Configured URLSession for Production

Use a configured session for production clients instead of calling URLSession.shared from request methods. Set explicit request/resource timeouts, cache behavior, connectivity policy, and any delegates needed for authentication challenges, redirects, metrics, pinning boundaries, or background transfers before creating the URLSession.

enum SessionFactory {
    static func makeDefault(delegate: (any URLSessionDelegate)? = nil) -> URLSession {
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 30
        config.timeoutIntervalForResource = 300
        config.waitsForConnectivity = true
        config.httpMaximumConnectionsPerHost = 6
        config.requestCachePolicy = .useProtocolCachePolicy
        config.httpAdditionalHeaders = [
            "Accept": "application/json",
            "Accept-Encoding": "gzip, deflate, br",
        ]

        let cache = URLCache(
            memoryCapacity: 25 * 1024 * 1024,
            diskCapacity: 100 * 1024 * 1024
        )
        config.urlCache = cache

        return URLSession(
            configuration: config,
            delegate: delegate,
            delegateQueue: nil
        )
    }
}

Source: SKILL.md on GitHub

No alerts17d4 checks · Risk SAFE
  • Gen Agent Trust Hub17d

    The skill provides secure patterns for iOS and macOS networking using URLSession and Network.framework. It correctly identifies security best practices, such as using Keychain for tokens and avoiding force-unwraps. A low-risk surface for indirect prompt injection exists as the skill facilitates fetching external data.

  • Socket17d

    No alerts

  • Snyk17d

    Risk: LOW · No issues

  • ZeroLeaks5mo

    1 finding · Score: 82/100

Signed by skilld at cf3fe87. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 3 days ago.

Steadyupdated 3 months ago
  • ios
  • urlsession
  • async-await
  • networking
  • swift
  • rest-api
  • http
  • error-handling
  • pagination

README badge

README badge for dpearson2699/swift-ios-skills/ios-networking

Handles REST APIs, file downloads/uploads, WebSockets, pagination, retry logic, and network reachability in iOS/macOS apps using URLSession with async/await and structured concurrency. Covers API client architecture, request middleware, token refresh, error handling, and App Transport Security configuration for Swift 6.3+.

Generated from the current SKILL.md.

Does this skill work with iOS versions before iOS 15?
No. The skill targets iOS 15+ because URLSession's native async/await overloads were introduced in iOS 15. It explicitly does not cover completion-handler variants used in older code.
Does this skill cover third-party networking libraries like Alamofire?
No. The skill focuses exclusively on URLSession with async/await and structured concurrency. It does not require or discuss third-party dependencies.
What Swift version does this skill target?
Swift 6.3. All examples use modern Swift patterns including structured concurrency and Sendable conformance.
Does this cover WebSocket connections?
The skill description mentions WebSocket connections as a use case, but the provided content does not include WebSocket implementation details — only URLSession patterns for HTTP requests, downloads, uploads, and streaming.
Should I use URLSession.shared or create a custom session?
Use URLSession.shared only for simple, one-off requests. Production code should create a configured URLSession with custom timeout intervals, cache policies, and headers.

Generated from the current SKILL.md. These answers refresh after source changes.