> ## Documentation Index
> Fetch the complete documentation index at: https://docs.swiftaiboilerplate.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Networking Module

> Production-ready async HTTP client with interceptors, retry/backoff, caching, and AppError mapping

The **Networking** module is a production-grade HTTP layer with:

* **A single async `HTTPClient` API** (`send(_:) -> HTTPResponse`)
* **Interceptors** for auth, headers, and retry decisions
* **Configurable retry** (exponential backoff with jitter, `Retry-After` aware)
* **Response caching** (URLCache-backed, with optional synthetic TTL)
* **Error mapping** to the app-wide `AppError` type

<Info>
  Full technical details live in your project at `docs/modules/Networking.md`.
</Info>

## What's new in v2.2.0

* Swift 6 strict-concurrency clean: `HTTPClient`, `HTTPInterceptor`, `TokenProvider`, `HTTPRequest`, and `HTTPResponse` are all `Sendable`; protocol-typed properties use explicit `any`.
* `URLSessionHTTPClient.applySyntheticTTL` guards on a resolvable URL instead of force-unwrapping, so it stays safe under malformed request inputs. No public API change.

## Purpose

Handles:

* **HTTP requests** via a single `HTTPClient.send(_:)` API
* **Cross-cutting concerns** (auth tokens, app/telemetry headers, retry) through interceptors
* **Consistent error handling** by mapping transport and HTTP errors to `AppError`

## Core Abstractions

### HTTPClient Protocol

```swift theme={null}
public protocol HTTPClient: Sendable {
    func send(_ request: HTTPRequest) async throws -> HTTPResponse
}
```

### HTTPRequest

`HTTPRequest` is a concrete `Sendable` value type — there is no per-endpoint protocol to conform to. Build one with a path and method, then layer headers, query, body, and an optional cache policy via the `with…` helpers.

```swift theme={null}
public struct HTTPRequest: Sendable {
    public let path: String
    public let method: HTTPMethod
    public let headers: [String: String]
    public let query: [String: String?]
    public let body: Data?
    public let cachePolicy: CachePolicy?
}

// Build a request
let request = HTTPRequest(path: "/users/123", method: .get)
    .withHeader("Accept", "application/json")
    .withQuery("expand", "profile")

// Send it and decode the body
let response = try await httpClient.send(request)
let user = try JSONDecoder().decode(User.self, from: response.data)
```

`HTTPMethod` covers `.get`, `.post`, `.put`, `.patch`, `.delete`, `.head`, and `.options`.

### HTTPResponse

`send(_:)` returns an `HTTPResponse`, not a decoded model — you decode `response.data` yourself.

```swift theme={null}
public struct HTTPResponse: Sendable {
    public let statusCode: Int
    public let headers: [String: String]
    public let data: Data
    public let requestID: String?      // extracted from X-Request-ID (case-insensitive)

    public var isSuccess: Bool         // 200..<300
    public var isClientError: Bool     // 400..<500
    public var isServerError: Bool     // 500..<600
}
```

## Interceptors

Interceptors conform to `HTTPInterceptor` and are applied in order. Each can mutate the outgoing `URLRequest` (`adapt`) and/or vote on whether a failed attempt should be retried (`shouldRetry`).

```swift theme={null}
public protocol HTTPInterceptor: Sendable {
    func adapt(_ request: inout URLRequest)
    func shouldRetry(
        response: HTTPURLResponse?,
        data: Data?,
        error: Error?,
        attempt: Int
    ) -> RetryDecision   // .noRetry or .retry(after: TimeInterval?)
}
```

Three interceptors ship out of the box:

<Tabs>
  <Tab title="AuthInterceptor">
    **Bearer-token authentication**

    `AuthInterceptor(tokenProvider:)` adds an `Authorization: Bearer {token}` header:

    * Reads the current token from an injected `TokenProvider`
    * Only sets the header if one is not already present (respects manual overrides)
    * Does nothing when `currentToken()` returns `nil`
    * Returns `.noRetry` — token-refresh logic stays in the Auth module to avoid coupling

    The app wires this with `Storage.KeychainTokenProvider`, so tokens come from the secure iOS Keychain.

    ```swift theme={null}
    let authInterceptor = AuthInterceptor(tokenProvider: keychainTokenProvider)
    ```
  </Tab>

  <Tab title="RetryInterceptor">
    **Retry decisions with exponential backoff**

    `RetryInterceptor(policy:)` decides when to retry, while `URLSessionHTTPClient` performs the wait and re-issues the request:

    * Honors the policy's `maxAttempts` (default 3)
    * Retries transient transport errors: timeout, connection lost, host/DNS failures
    * Retries HTTP `408`, `425`, `429`, and `500–599`
    * Honors a server `Retry-After` header; otherwise falls back to backoff
    * Cancellation passes straight through (no retry)

    ```swift theme={null}
    let retryInterceptor = RetryInterceptor(policy: RetryPolicy(maxAttempts: 3))
    ```
  </Tab>

  <Tab title="HeadersInterceptor">
    **Standard app + telemetry headers**

    `HeadersInterceptor(appVersion:platform:deviceModel:extraHeaders:)` adds, only when not already present:

    * `User-Agent` (app version + platform + device model)
    * `X-App-Version`
    * `X-Platform` (default `"iOS"`)
    * `X-Device-Model` (auto-detected on iOS, optional elsewhere)
    * Any `extraHeaders` you pass

    Request-specific headers always take precedence over these defaults.
  </Tab>
</Tabs>

## RetryPolicy

`RetryPolicy` configures the backoff math used between attempts:

```swift theme={null}
public struct RetryPolicy: Sendable {
    public let maxAttempts: Int       // default 3
    public let baseDelay: TimeInterval // default 0.5s
    public let maxDelay: TimeInterval  // default 8.0s
    public let jitter: Double          // 0.0...1.0, default 0.2

    public func nextDelay(for attempt: Int) -> TimeInterval
}
```

Delay is `baseDelay * 2^(attempt-1)`, capped at `maxDelay`, then randomized by ±`jitter`.

## Configuration

The boilerplate wires the client in `CompositionRoot`. Interceptors run in array order (`headers → auth → retry`):

```swift theme={null}
let tokenProvider = KeychainTokenProvider(
    keychain: keychainStore,
    tokenKey: KeychainStore.Keys.authAccessToken
)

let httpClient = URLSessionHTTPClient(
    baseURL: proxyBaseURL,
    session: .shared,
    defaultHeaders: ["Accept": "application/json"],
    interceptors: [
        HeadersInterceptor(appVersion: "1.0.0", platform: "iOS"),
        AuthInterceptor(tokenProvider: tokenProvider),
        RetryInterceptor(policy: RetryPolicy(maxAttempts: 3))
    ]
)
```

When no proxy base URL is configured, the app falls back to an `UnconfiguredHTTPClient` that fails fast instead of silently hitting a placeholder host.

## Customization Examples

### Add a custom request

Because `HTTPRequest` is a value type, a "custom request" is just a factory that returns one:

```swift theme={null}
func createConversationRequest(title: String) throws -> HTTPRequest {
    let body = try JSONEncoder().encode(["title": title])
    return HTTPRequest(path: "/conversations", method: .post, body: body)
        .withHeader("Content-Type", "application/json")
}

let response = try await httpClient.send(createConversationRequest(title: "New Chat"))
let conversation = try JSONDecoder().decode(ConversationDTO.self, from: response.data)
```

### Add a custom interceptor

```swift theme={null}
struct AnalyticsInterceptor: HTTPInterceptor {
    let analytics: AnalyticsClient

    func adapt(_ request: inout URLRequest) {
        analytics.log(event: "api_call", properties: [
            "path": request.url?.path ?? "",
            "method": request.httpMethod ?? ""
        ])
    }

    func shouldRetry(
        response: HTTPURLResponse?,
        data: Data?,
        error: Error?,
        attempt: Int
    ) -> RetryDecision {
        .noRetry
    }
}

// Add it to the client's interceptor array
let interceptors: [any HTTPInterceptor] = [
    headersInterceptor,
    authInterceptor,
    analyticsInterceptor,
    retryInterceptor
]
```

## Error Handling

Transport failures and non-2xx responses are mapped to `Core.AppError` before they reach you:

```swift theme={null}
// Transport errors -> AppError.network(code:message:)
AppError.network(code: -1009, message: "Offline")
AppError.network(code: -1001, message: "Timed out")

// Cancellation
AppError.cancelled

// HTTP status (4xx/5xx) -> AppError.network with the status code,
// using the server's error/message/detail field when present
AppError.network(code: 500, message: "Server error")
```

## Key Files

| Component           | Location                                                            |
| ------------------- | ------------------------------------------------------------------- |
| HTTPClient protocol | `Packages/Networking/Sources/Networking/HTTPClient.swift`           |
| URLSession client   | `Packages/Networking/Sources/Networking/URLSessionHTTPClient.swift` |
| Interceptors        | `Packages/Networking/Sources/Networking/Interceptors/`              |
| Retry policy        | `Packages/Networking/Sources/Networking/Retry/RetryPolicy.swift`    |
| Caching             | `Packages/Networking/Sources/Networking/Caching/`                   |
| AppError mapping    | `Packages/Networking/Sources/Networking/AppError+Networking.swift`  |

## Dependencies

* **Core** — `AppError`, `AppLogger`

## Used By

* **Auth** — Supabase auth calls (`SupabaseAuthAPI`, `SessionManager`)
* **AI** — `ProxyLLMClient` proxy requests to the Supabase Edge Function
* **Your services** — any API client you build on top of `HTTPClient`

## Best Practices

<AccordionGroup>
  <Accordion title="Request Design">
    * Build `HTTPRequest` values with the `with…` helpers
    * Decode `response.data` at the call site
    * Encode bodies with `JSONEncoder` and set `Content-Type`
    * Clear naming (verb + noun) for request factories
  </Accordion>

  <Accordion title="Error Handling">
    * Catch `AppError` from `send(_:)`
    * Let `RetryInterceptor` handle idempotent/transient retries
    * Surface user-facing copy via `AppError.localizedUserMessage`
  </Accordion>

  <Accordion title="Interceptors">
    * Order matters — headers and auth before retry
    * Keep each interceptor focused on one concern
    * Test interceptors in isolation
    * Return `.noRetry` unless the interceptor owns retry logic
  </Accordion>
</AccordionGroup>

## Learn More

<CardGroup cols={2}>
  <Card title="Full Documentation" icon="book" href="https://github.com/SwiftAIBoilerplatePro/SwiftAIBoilerplatePro-Distribution/blob/main/docs/modules/Networking.md">
    Complete Networking guide
  </Card>

  <Card title="Auth Module" icon="shield" href="/pages/modules/auth">
    Uses HTTPClient for API calls
  </Card>

  <Card title="AI Module" icon="brain" href="/pages/modules/ai">
    Uses HTTPClient for the proxy
  </Card>

  <Card title="Architecture" icon="diagram-project" href="/pages/architecture">
    See the networking layer
  </Card>
</CardGroup>

## Test Coverage

The Networking package ships with its own `NetworkingTests` target (client, interceptor ordering, retry policy, caching, URL builder). It runs as one of the 12 package test targets in the suite of **\~598 tests across 12 package test targets + the app test suites**, executed in a single `Boilerplate.xctestplan` pass.
