All posts
Swift Networking iOS Open Source async/await

PulseNetworking: Modern Swift Networking with async/await

Most iOS apps spend a significant portion of their code talking to APIs. Over the years I’ve written the same networking boilerplate dozens of times: URLSession wrappers, JSON decoding helpers, retry logic, auth header injection. At some point I decided to extract that into a reusable library.

PulseNetworking is the result — a lightweight Swift networking library built around async/await, designed to cover the common 90% of iOS networking needs without the overhead of larger frameworks.

Installation

Add it via Swift Package Manager in Package.swift:

dependencies: [
    .package(url: "https://github.com/cbarbera80/PulseNetworking.git", branch: "main")
]

Or directly from Xcode: File → Add Package Dependencies → paste the URL.

Supports iOS 14.0+, macOS 11.0+, watchOS 7.0+, tvOS 14.0+. Requires Swift 5.9+.


The Builder Pattern

The entry point is NetworkClientBuilder. Every option is chainable — you configure what you need and call .build():

import PulseNetworking

let client = NetworkClientBuilder()
    .withBaseURL("https://api.example.com")
    .build()

That’s the minimal setup. From here, every request is automatically prefixed with the base URL:

// Hits https://api.example.com/users/1
let user: User = try await client.get("/users/1")

The type inference is the key: User must conform to Decodable, and the library handles JSON decoding automatically.


Making Requests

NetworkClient exposes a method for each HTTP verb, all with the same signature pattern:

// GET — retrieve data
let users: [User] = try await client.get("/users")

// POST — send a body, get a response
let newUser: User = try await client.post("/users", body: CreateUserRequest(
    name: "Claudio",
    email: "claudio@example.com"
))

// PUT — full replacement
let updated: User = try await client.put("/users/42", body: updateRequest)

// PATCH — partial update
let patched: User = try await client.patch("/users/42", body: patchRequest)

// DELETE
let result: DeleteResponse = try await client.delete("/users/42")

For post, put, and patch, the body must conform to Encodable. The library encodes it as JSON and sets Content-Type: application/json automatically — unless you’ve already set it in custom headers.

Custom requests

When you need full control over the request — custom timeout, raw body, specific headers — use NetworkRequest directly:

var request = NetworkRequest(
    url: URL(string: "https://api.example.com/upload")!,
    method: .post,
    headers: ["X-Upload-Type": "avatar"],
    body: imageData,
    timeoutInterval: 60
)

let response: UploadResponse = try await client.request(request)

NetworkRequest is a plain struct — it’s easy to build, copy, and modify.


Interceptors

Interceptors sit between the client and URLSession. Every outgoing URLRequest passes through the interceptor chain in order before being sent. They’re async and throwing, so you can do any async work — including waiting for a token refresh.

The protocol is simple:

public protocol NetworkInterceptor: Sendable {
    func intercept(_ request: URLRequest) async throws -> URLRequest
}

AuthInterceptor

Injects a Bearer token from an async closure. The closure is called for every request, so it naturally supports dynamic tokens (e.g., refreshed JWTs):

let auth = AuthInterceptor {
    // Called per-request — safe to await Keychain or token refresh
    await TokenStore.shared.currentToken()
}

let client = NetworkClientBuilder()
    .withBaseURL("https://api.example.com")
    .withInterceptor(auth)
    .build()

If tokenProvider returns nil, the Authorization header is simply not added — no crash, no throw.

LoggingInterceptor

Prints the method and URL for every outgoing request. Useful during development:

let client = NetworkClientBuilder()
    .withBaseURL("https://api.example.com")
    .withInterceptor(LoggingInterceptor())
    .build()

// Console output:
// 📤 [Network] GET https://api.example.com/users/1
//    Headers: ["Authorization": "Bearer eyJ..."]

CustomHeaderInterceptor

Adds static headers to every request — useful for API version headers, client identifiers, or tracking:

let headers = CustomHeaderInterceptor(headers: [
    "X-API-Version": "2",
    "X-Client-ID": "ios-app"
])

Chaining interceptors

Use .withInterceptors([]) to add multiple at once. They execute in array order:

let client = NetworkClientBuilder()
    .withBaseURL("https://api.example.com")
    .withInterceptors([
        CustomHeaderInterceptor(headers: ["X-API-Version": "2"]),
        AuthInterceptor { await getToken() },
        LoggingInterceptor()  // logs after auth header is added
    ])
    .build()

Custom interceptors

Implement NetworkInterceptor for any custom logic — rate limiting, request signing, A/B test flags:

final class RateLimitInterceptor: NetworkInterceptor {
    private var requestCount = 0
    private let limit = 100

    func intercept(_ request: URLRequest) async throws -> URLRequest {
        requestCount += 1
        guard requestCount <= limit else {
            throw NetworkError.custom("Rate limit exceeded")
        }
        return request
    }
}

Retry Policies

Network failures happen. PulseNetworking has three built-in policies and supports custom ones.

ExponentialBackoffRetryPolicy

The most useful policy for production apps. Retries with exponentially increasing delays:

Attempt 1 fails → wait 1s → Attempt 2
Attempt 2 fails → wait 2s → Attempt 3
Attempt 3 fails → wait 4s → Attempt 4 (if maxRetries allows)

By default it retries on network-level errors (timedOut, networkConnectionLost, notConnectedToInternet) and specific HTTP status codes: 408, 429, 500, 502, 503, 504.

let client = NetworkClientBuilder()
    .withBaseURL("https://api.example.com")
    .withExponentialBackoffRetry(
        maxRetries: 3,
        initialDelay: 1.0,  // seconds
        maxDelay: 30.0       // cap — won't wait longer than this
    )
    .build()

The delay formula is initialDelay * (2 ^ (attempt - 1)), capped at maxDelay. For 3 retries with default settings: 1s, 2s, 4s.

SimpleRetryPolicy

Fixed delay between attempts. Good for simple cases where you just want to retry a couple of times without a complex backoff:

let client = NetworkClientBuilder()
    .withBaseURL("https://api.example.com")
    .withSimpleRetry(maxRetries: 2, delayInterval: 2.0)
    .build()

Custom retry policies

Implement RetryPolicy when you need different logic — retrying on specific status codes, Fibonacci backoff, or app-level conditions:

final class FibonacciRetryPolicy: RetryPolicy {
    private let maxRetries: Int
    private let delays: [TimeInterval] = [1, 1, 2, 3, 5, 8, 13, 21]

    init(maxRetries: Int = 5) {
        self.maxRetries = maxRetries
    }

    func shouldRetry(_ error: Error, attempt: Int) -> Bool {
        guard attempt <= maxRetries else { return false }
        if let urlError = error as? URLError {
            return [.timedOut, .networkConnectionLost].contains(urlError.code)
        }
        return false
    }

    func delayBeforeRetry(attempt: Int) -> TimeInterval {
        delays[min(attempt - 1, delays.count - 1)]
    }
}

let client = NetworkClientBuilder()
    .withBaseURL("https://api.example.com")
    .withRetryPolicy(FibonacciRetryPolicy(maxRetries: 5))
    .build()

In-Memory Caching

The caching layer avoids redundant network calls for data that doesn’t change frequently. Internally it uses a thread-safe dictionary with TTL tracking.

let client = NetworkClientBuilder()
    .withBaseURL("https://api.example.com")
    .withCache(enabled: true, duration: 300) // 5 minutes
    .build()

// First call: hits the network, stores response
let categories: [Category] = try await client.get("/categories")

// Second call within 5 minutes: returns cached data, no network request
let cached: [Category] = try await client.get("/categories")

The cache key is derived from "METHOD_url" — so /categories and /categories?page=2 are stored separately.

When cached data is corrupted (e.g., schema changed), the library automatically invalidates the entry and fetches fresh data, so a bad cache never blocks the app.

Custom cache

Implement NetworkCacheProtocol to plug in any backing store — disk cache, NSCache, encrypted storage:

public protocol NetworkCacheProtocol {
    func get(for key: String) async -> Data?
    func set(_ data: Data, for key: String, expiresIn duration: TimeInterval) async
    func remove(for key: String) async
}

Then inject it with .withCustomCache(_:):

let client = NetworkClientBuilder()
    .withBaseURL("https://api.example.com")
    .withCustomCache(DiskCache(directory: .cachesDirectory))
    .build()

Error Handling

All errors are wrapped in NetworkError, a LocalizedError enum with typed cases:

do {
    let user: User = try await client.get("/users/999")
} catch NetworkError.httpError(let statusCode, let data) {
    // HTTP 404, 401, 500, etc.
    if statusCode == 401 {
        await logout()
    } else if statusCode == 404 {
        showNotFound()
    }
} catch NetworkError.decodingFailed(let error) {
    // The JSON didn't match your model
    print("Model mismatch: \(error)")
} catch NetworkError.requestFailed(let urlError) {
    // URLSession-level error (no connection, timeout...)
    if urlError.code == .notConnectedToInternet {
        showOfflineBanner()
    }
} catch NetworkError.retryExhausted(let underlying, let attempts) {
    print("Gave up after \(attempts) attempts: \(underlying)")
} catch {
    print("Unexpected: \(error)")
}

The full error enum:

CaseWhen it occurs
.invalidURLURL string can’t be parsed
.requestFailedURLSession throws (timeout, no connection)
.invalidResponseResponse is not HTTPURLResponse
.httpError(statusCode:data:)Status code outside 200–299
.decodingFailedJSONDecoder can’t decode the response
.encodingFailedJSONEncoder can’t encode the request body
.noDataEmpty response body
.retryExhausted(error:attempts:)All retry attempts failed
.cacheErrorCache read/write failure
.customUser-defined errors from interceptors

Testing

PulseNetworking is designed to be testable from the ground up. URLSession is abstracted behind URLSessionProtocol:

public protocol URLSessionProtocol: Sendable {
    func data(for request: URLRequest) async throws -> (Data, URLResponse)
}

In tests, inject a mock instead of the live session:

final class MockURLSession: URLSessionProtocol {
    var stubbedData: Data = Data()
    var stubbedResponse: URLResponse = HTTPURLResponse(
        url: URL(string: "https://example.com")!,
        statusCode: 200,
        httpVersion: nil,
        headerFields: nil
    )!
    var stubbedError: Error?

    func data(for request: URLRequest) async throws -> (Data, URLResponse) {
        if let error = stubbedError { throw error }
        return (stubbedData, stubbedResponse)
    }
}

// In your test:
func testFetchUserDecodesCorrectly() async throws {
    let mockSession = MockURLSession()
    let userJSON = #"{"id": 1, "name": "Claudio", "email": "c@example.com"}"#
    mockSession.stubbedData = userJSON.data(using: .utf8)!

    let client = NetworkClientBuilder()
        .withBaseURL("https://api.example.com")
        .withSession(mockSession)
        .build()

    let user: User = try await client.get("/users/1")
    #expect(user.name == "Claudio")
    #expect(user.id == 1)
}

func testHTTPErrorThrowsCorrectCase() async throws {
    let mockSession = MockURLSession()
    mockSession.stubbedResponse = HTTPURLResponse(
        url: URL(string: "https://api.example.com/users/999")!,
        statusCode: 404,
        httpVersion: nil,
        headerFields: nil
    )!
    mockSession.stubbedData = Data()

    let client = NetworkClientBuilder()
        .withBaseURL("https://api.example.com")
        .withSession(mockSession)
        .build()

    do {
        let _: User = try await client.get("/users/999")
        Issue.record("Expected error was not thrown")
    } catch NetworkError.httpError(let statusCode, _) {
        #expect(statusCode == 404)
    }
}

Putting It All Together

A realistic production setup for an authenticated app with retry, caching, and logging:

// AppNetworking.swift

extension NetworkClient {
    static let shared: NetworkClient = {
        NetworkClientBuilder()
            .withBaseURL("https://api.myapp.com/v2")
            .withInterceptors([
                CustomHeaderInterceptor(headers: [
                    "X-App-Version": Bundle.main.shortVersion,
                    "X-Platform": "iOS"
                ]),
                AuthInterceptor {
                    await KeychainManager.shared.accessToken()
                },
                LoggingInterceptor()
            ])
            .withExponentialBackoffRetry(maxRetries: 3, initialDelay: 1.0, maxDelay: 15.0)
            .withCache(enabled: true, duration: 120)
            .build()
    }()
}

// Usage anywhere in the app:
let timeline: [Post] = try await NetworkClient.shared.get("/timeline")
let profile: User = try await NetworkClient.shared.get("/me")

PulseNetworking is open source and available on GitHub. Contributions, issues, and pull requests are welcome. If you’re using it in a project and run into an edge case, open an issue.