Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,16 @@

### 🔍 상세

## 📸 영상 / 이미지 (Optional)
## 🔄 변경 흐름

<!--
기존과 현재 흐름을 하나의 Mermaid 다이어그램에 작성합니다.
공통 노드는 한 번만 정의하고, 현재 연결은 실선으로 작성합니다.
제거된 기존 연결만 `-. 제거된 연결 .->` 형식의 점선으로 표시합니다.
-->

```mermaid
flowchart LR
start[시작] --> shared[공통 처리] --> current[현재 결과]
start -. 제거된 연결 .-> current
```
25 changes: 25 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Nexa는 `URLSession` 기반의 SwiftUI 스타일 선언형 네트워킹 라이
- [Endpoint API](#endpoint-api)
- [요청 전환](#요청-전환)
- [설정](#설정)
- [전송 metrics](#전송-metrics)
- [인증 토큰 갱신](#인증-토큰-갱신)
- [응답 cache](#응답-cache)
- [재시도 정책](#재시도-정책)
Expand All @@ -37,6 +38,7 @@ Nexa는 `URLSession` 기반의 SwiftUI 스타일 선언형 네트워킹 라이
- [x] 응답 유효성 검사 및 서버 에러 디코딩
- [x] 성공한 `GET` 응답과 진행 중인 동일 요청을 위한 validator 재검증 선택형 memory response cache
- [x] 로거 훅 및 테스트를 위한 transport 추상화
- [x] `Sendable` URLSession task metrics observer

## 요구 사항

Expand Down Expand Up @@ -89,6 +91,7 @@ dependencies: [
| `NXAuthTokenProvider` | `.authorized()` 요청에 토큰 조회 및 갱신 기능이 필요할 때 | `authTokenProvider: yourAuthTokenProvider` |
| `NXServerErrorDecoder` | 실패 응답을 도메인 에러로 디코딩할 때 | `serverErrorDecoder: yourServerErrorDecoder` |
| `NXLogger` | 구조화된 요청 생명주기 로깅이 필요할 때 | `logger: yourLogger` |
| `NXNetworkMetricsObserver` | logger event 변경 없이 URLSession task 시간 snapshot이 필요할 때 | `NXURLSessionTransport(metricsObserver: yourObserver)` |
| `NXRawResponse` | `Data`와 `HTTPURLResponse`를 직접 다뤄야 할 때 | `let response = try await client.get("/users").send()` |
| `NXError` | 호출 코드에서 Nexa 고유 오류를 처리할 때 | `catch let error as NXError` |
| `NXHTTPMethod` | `NXEndpoint`의 메서드를 정의할 때 | `var method: NXHTTPMethod { .post }` |
Expand Down Expand Up @@ -169,6 +172,7 @@ struct UserEndpoint: NXEndpoint {
- `NXAuthTokenProvider`: bearer token 주입 및 갱신
- `NXServerErrorDecoder`: 서버 payload를 도메인 에러로 매핑
- `NXLogger`: 요청 생명주기 로깅 및 관측성
- `NXNetworkMetricsObserver`: URLSession task 시간 snapshot

## 요청 흐름

Expand Down Expand Up @@ -348,6 +352,27 @@ let client = NXAPIClient(configuration: configuration)
- 토큰 갱신 및 재시도 처리
- 스터빙 및 격리 테스트를 위한 커스텀 transport

## 전송 metrics

`NXURLSessionTransport`는 `URLSession` task마다 `NXNetworkMetrics` snapshot 하나를 `NXNetworkMetricsObserver`에 전달할 수 있습니다. snapshot에는 task duration, redirect 수, transaction 수, 수집 순서를 보존한 `NXNetworkTransactionMetrics` 값이 포함됩니다.

각 transaction은 Foundation timestamp의 시작과 끝이 모두 있을 때만 DNS, connection, TLS, request 시작부터 first byte까지의 duration을 나타냅니다. timestamp가 없으면 해당 값은 `nil`입니다. 재사용 connection은 `isConnectionReused`로 나타내며 DNS 또는 connection duration이 `nil`일 수 있습니다.

```swift
import Foundation
import Nexa

actor MetricsObserver: NXNetworkMetricsObserver {
func record(_ metrics: NXNetworkMetrics) async {
print(metrics.taskDuration ?? 0)
}
}

let transport = NXURLSessionTransport(metricsObserver: MetricsObserver())
```

snapshot은 `NXURLSessionTransport`만 수집합니다. 커스텀 `NXHTTPTransport`와 cache hit는 metrics를 합성하지 않습니다. observer 전달은 요청 완료를 지연하지 않으며 `NXLogger` event와의 순서는 보장하지 않습니다.

## 인증 토큰 갱신

하나의 `NXAPIClient`에서 생성한 `.authorized()` 요청이 동시에 `401` 응답을 받으면 진행 중인 토큰 갱신 결과 하나를 공유합니다. 해당 client의 값 복사본과 여기서 파생한 builder도 같은 갱신을 공유합니다. 별도로 생성한 `NXAPIClient`는 독립된 갱신 수명을 가집니다.
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Nexa is a SwiftUI-inspired declarative networking library built on `URLSession`.
- [Endpoint API](#endpoint-api)
- [Request Migration](#request-migration)
- [Configuration](#configuration)
- [Transport Metrics](#transport-metrics)
- [Authentication Refresh](#authentication-refresh)
- [Response Cache](#response-cache)
- [Retry Policy](#retry-policy)
Expand All @@ -37,6 +38,7 @@ Nexa is a SwiftUI-inspired declarative networking library built on `URLSession`.
- [x] Response validation and server error decoding
- [x] Memory response cache with optional validator revalidation for successful `GET` responses and in-flight identical requests
- [x] Logger hooks and transport abstraction for testing
- [x] `Sendable` URLSession task metrics observer

## Requirements

Expand Down Expand Up @@ -89,6 +91,7 @@ The rest of the public surface is made of extension points for auth, logging, te
| `NXAuthTokenProvider` | When `.authorized()` requests need token lookup and refresh support | `authTokenProvider: yourAuthTokenProvider` |
| `NXServerErrorDecoder` | When failed responses should decode into your own domain error | `serverErrorDecoder: yourServerErrorDecoder` |
| `NXLogger` | When you want structured request lifecycle logging | `logger: yourLogger` |
| `NXNetworkMetricsObserver` | When you want URLSession task timing snapshots without changing logger events | `NXURLSessionTransport(metricsObserver: yourObserver)` |
| `NXRawResponse` | When you need both `Data` and `HTTPURLResponse` directly | `let response = try await client.get("/users").send()` |
| `NXError` | When handling Nexa-specific failures in calling code | `catch let error as NXError` |
| `NXHTTPMethod` | When defining an `NXEndpoint` method | `var method: NXHTTPMethod { .post }` |
Expand Down Expand Up @@ -169,6 +172,7 @@ Use the lower-level protocols only when the default behavior is not enough:
- `NXAuthTokenProvider`: bearer token injection and refresh
- `NXServerErrorDecoder`: server payload to domain error mapping
- `NXLogger`: request lifecycle logging and observability
- `NXNetworkMetricsObserver`: URLSession task timing snapshots

## Request Flow

Expand Down Expand Up @@ -348,6 +352,27 @@ Nexa currently supports:
- Token refresh and retry handling
- Custom transports for stubbing and isolated tests

## Transport Metrics

`NXURLSessionTransport` can forward one `NXNetworkMetrics` snapshot to an `NXNetworkMetricsObserver` for each `URLSession` task. The snapshot contains task duration, redirect count, transaction count, and ordered `NXNetworkTransactionMetrics` values.

Each transaction reports DNS, connection, TLS, and request-to-first-byte durations only when both Foundation timestamps exist. A missing timestamp remains `nil`. A reused connection is represented by `isConnectionReused` and can have `nil` DNS or connection durations.

```swift
import Foundation
import Nexa

actor MetricsObserver: NXNetworkMetricsObserver {
func record(_ metrics: NXNetworkMetrics) async {
print(metrics.taskDuration ?? 0)
}
}

let transport = NXURLSessionTransport(metricsObserver: MetricsObserver())
```

Only `NXURLSessionTransport` collects these snapshots. A custom `NXHTTPTransport` and a cache hit do not synthesize metrics. Observer delivery does not delay request completion, and its order relative to `NXLogger` events is not guaranteed.

## Authentication Refresh

Concurrent `401` responses from `.authorized()` requests created by one `NXAPIClient` share one in-progress token refresh. Copies of that client and builders derived from it share the same refresh. A separately initialized `NXAPIClient` has an independent refresh lifetime.
Expand Down
126 changes: 126 additions & 0 deletions Sources/Core/NXNetworkMetrics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//
// NXNetworkMetrics.swift
// Nexa
//
// Created by opfic on 8/23/26.
//

import Foundation

/// Value snapshot of one `URLSession` task's network activity.
///
/// `NXURLSessionTransport` creates this value before delivering it to an `NXNetworkMetricsObserver`.
/// Custom `NXHTTPTransport` implementations do not produce this value.
public struct NXNetworkMetrics: Sendable, Equatable {
/// Total duration of the URL loading task.
public let taskDuration: TimeInterval?
/// Number of redirects followed by the URL loading task.
public let redirectCount: Int
/// Number of transactions collected for the URL loading task.
public let transactionCount: Int
/// Transaction snapshots in the order reported by `URLSession`.
public let transactions: [NXNetworkTransactionMetrics]

/// Creates a network metrics snapshot.
public init(
taskDuration: TimeInterval?,
redirectCount: Int,
transactionCount: Int,
transactions: [NXNetworkTransactionMetrics]
) {
self.taskDuration = taskDuration
self.redirectCount = redirectCount
self.transactionCount = transactionCount
self.transactions = transactions
}
}

/// Value snapshot of one URL loading transaction.
public struct NXNetworkTransactionMetrics: Sendable, Equatable {
/// Duration of DNS lookup when URLSession reports both timestamps.
public let domainLookupDuration: TimeInterval?
/// Duration from connection start through connection end when URLSession reports both timestamps.
public let connectionDuration: TimeInterval?
/// Duration of TLS negotiation when URLSession reports both timestamps.
public let secureConnectionDuration: TimeInterval?
/// Duration from request start until the first response byte when URLSession reports both timestamps.
public let timeToFirstByte: TimeInterval?
/// Whether URLSession reused an existing connection.
public let isConnectionReused: Bool

/// Creates a transaction metrics snapshot.
public init(
domainLookupDuration: TimeInterval?,
connectionDuration: TimeInterval?,
secureConnectionDuration: TimeInterval?,
timeToFirstByte: TimeInterval?,
isConnectionReused: Bool
) {
self.domainLookupDuration = domainLookupDuration
self.connectionDuration = connectionDuration
self.secureConnectionDuration = secureConnectionDuration
self.timeToFirstByte = timeToFirstByte
self.isConnectionReused = isConnectionReused
}
}

protocol NXNetworkMetricsSource {
associatedtype Transaction: NXNetworkTransactionMetricsSource

var taskInterval: DateInterval { get }
var redirectCount: Int { get }
var transactions: [Transaction] { get }
}

protocol NXNetworkTransactionMetricsSource {
var domainLookupStartDate: Date? { get }
var domainLookupEndDate: Date? { get }
var connectStartDate: Date? { get }
var connectEndDate: Date? { get }
var secureConnectionStartDate: Date? { get }
var secureConnectionEndDate: Date? { get }
var requestStartDate: Date? { get }
var responseStartDate: Date? { get }
var isConnectionReused: Bool { get }
}

extension NXNetworkMetrics {
init<Source: NXNetworkMetricsSource>(source: Source) {
let transactions = source.transactions.map { transaction in
NXNetworkTransactionMetrics(
domainLookupDuration: Self.duration(
from: transaction.domainLookupStartDate,
to: transaction.domainLookupEndDate
),
connectionDuration: Self.duration(
from: transaction.connectStartDate,
to: transaction.connectEndDate
),
secureConnectionDuration: Self.duration(
from: transaction.secureConnectionStartDate,
to: transaction.secureConnectionEndDate
),
timeToFirstByte: Self.duration(
from: transaction.requestStartDate,
to: transaction.responseStartDate
),
isConnectionReused: transaction.isConnectionReused
)
}

self.init(
taskDuration: source.taskInterval.duration,
redirectCount: source.redirectCount,
transactionCount: transactions.count,
transactions: transactions
)
}

private static func duration(from startDate: Date?, to endDate: Date?) -> TimeInterval? {
guard let startDate, let endDate else {
return nil
}

return endDate.timeIntervalSince(startDate)
}
}
14 changes: 14 additions & 0 deletions Sources/Core/NXNetworkMetricsObserver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// NXNetworkMetricsObserver.swift
// Nexa
//
// Created by opfic on 8/23/26.
//

/// Receives metrics snapshots collected by `NXURLSessionTransport`.
///
/// Metrics delivery is independent of request completion and logger event order.
public protocol NXNetworkMetricsObserver: Sendable {
/// Records one network metrics snapshot.
func record(_ metrics: NXNetworkMetrics) async
}
23 changes: 22 additions & 1 deletion Sources/Nexa.docc/Nexa.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Use ``NXAPIClient`` to create an ``NXRequestBuilder`` from a relative path. Call

Adopt ``NXEndpoint`` when a request shape should be reusable and carry its response type with it. Its existing ``NXTypedRequestBuilder`` configuration contract remains available for endpoint compatibility.

Lower-level customization points such as ``NXHTTPTransport``, ``NXHTTPInterceptor``, ``NXAuthTokenProvider``, ``NXServerErrorDecoder``, and ``NXLogger`` let you replace or extend the default behavior without changing the higher-level request flow.
Lower-level customization points such as ``NXHTTPTransport``, ``NXHTTPInterceptor``, ``NXAuthTokenProvider``, ``NXServerErrorDecoder``, ``NXLogger``, and ``NXNetworkMetricsObserver`` let you replace or extend the default behavior without changing the higher-level request flow.

## Quick Start

Expand Down Expand Up @@ -77,6 +77,24 @@ Each ``NXAPIClient`` initializer creates one in-memory cache and in-flight reque

Nexa does not implement `Vary`, disk cache, full `Cache-Control` interpretation, or stale-if-error. Adding ``NXCache/revalidatingMemory(ttl:)`` adds an enum case, so consumers must update exhaustive `switch` statements over ``NXCache`` when recompiling.

## Transport Metrics

``NXURLSessionTransport`` can forward a ``NXNetworkMetrics`` value to an ``NXNetworkMetricsObserver``. The value contains task duration, redirect count, transaction count, and one ``NXNetworkTransactionMetrics`` value for each transaction reported by `URLSession`.

Each transaction records DNS, connection, TLS, and request-to-first-byte durations when both Foundation timestamps exist. A missing timestamp remains `nil`; a reused connection is represented by `isConnectionReused` and can therefore have `nil` DNS or connection durations.

```swift
actor MetricsObserver: NXNetworkMetricsObserver {
func record(_ metrics: NXNetworkMetrics) async {
print(metrics.taskDuration ?? 0)
}
}

let transport = NXURLSessionTransport(metricsObserver: MetricsObserver())
```

Only ``NXURLSessionTransport`` collects this value. A custom ``NXHTTPTransport`` and a cache hit do not synthesize metrics. Observer delivery is independent of request completion, and its order relative to ``NXLogger`` events is not guaranteed.

## Retry Policy

``NXRequestBuilder/retry(maxAttempts:backoff:retryableStatusCodes:allowing:maximumServerDelay:jitter:)`` retries `GET`, `HEAD`, `PUT`, `DELETE`, and `OPTIONS` by default when a configured status code or a retryable transport error occurs. It uses three attempts when `maxAttempts` is omitted. Add `POST` or `PATCH` through `allowing` only when the server can safely receive the same request more than once.
Expand Down Expand Up @@ -146,6 +164,8 @@ let user = try await client.send(UserEndpoint(identifier: 42))
- ``NXRetryBackoff``
- ``NXRetryJitter``
- ``NXURLSessionTransport``
- ``NXNetworkMetrics``
- ``NXNetworkTransactionMetrics``

### Extension Points

Expand All @@ -157,6 +177,7 @@ let user = try await client.send(UserEndpoint(identifier: 42))
- ``NXDefaultServerErrorDecoder``
- ``NXLogger``
- ``NXNoopLogger``
- ``NXNetworkMetricsObserver``

### Logging

Expand Down
65 changes: 65 additions & 0 deletions Sources/Runtime/NXURLSessionTaskMetricsDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//
// NXURLSessionTaskMetricsDelegate.swift
// Nexa
//
// Created by opfic on 8/23/26.
//

import Foundation

final class NXURLSessionTaskMetricsDelegate: NSObject, URLSessionTaskDelegate {
private let metricsObserver: any NXNetworkMetricsObserver

init(metricsObserver: any NXNetworkMetricsObserver) {
self.metricsObserver = metricsObserver
}

func urlSession(
_ session: URLSession,
task: URLSessionTask,
didFinishCollecting metrics: URLSessionTaskMetrics
) {
let source = NXURLSessionTaskMetricsSource(metrics: metrics)
let snapshot = NXNetworkMetrics(source: source)

Task { [metricsObserver] in
await metricsObserver.record(snapshot)
}
}
}

private struct NXURLSessionTaskMetricsSource: NXNetworkMetricsSource {
let taskInterval: DateInterval
let redirectCount: Int
let transactions: [NXURLSessionTaskTransactionMetricsSource]

init(metrics: URLSessionTaskMetrics) {
taskInterval = metrics.taskInterval
redirectCount = metrics.redirectCount
transactions = metrics.transactionMetrics.map(NXURLSessionTaskTransactionMetricsSource.init)
}
}

private struct NXURLSessionTaskTransactionMetricsSource: NXNetworkTransactionMetricsSource {
let domainLookupStartDate: Date?
let domainLookupEndDate: Date?
let connectStartDate: Date?
let connectEndDate: Date?
let secureConnectionStartDate: Date?
let secureConnectionEndDate: Date?
let requestStartDate: Date?
let responseStartDate: Date?
let isConnectionReused: Bool

init(metrics: URLSessionTaskTransactionMetrics) {
domainLookupStartDate = metrics.domainLookupStartDate
domainLookupEndDate = metrics.domainLookupEndDate
connectStartDate = metrics.connectStartDate
connectEndDate = metrics.connectEndDate
secureConnectionStartDate = metrics.secureConnectionStartDate
secureConnectionEndDate = metrics.secureConnectionEndDate
requestStartDate = metrics.requestStartDate
responseStartDate = metrics.responseStartDate
isConnectionReused = metrics.isReusedConnection
}
}
Loading