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
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@

All notable changes to NetworkingKit are documented in this file.

## 2.2.10 - Unreleased
## 2.3.1 - Unreleased

### Changed

- Correct HTTP cache revalidation by merging `304 Not Modified` metadata with cached headers.
- Store independent `Vary` response variants, bypass request `no-store`, honor `no-cache`, and reject `Vary: *` responses.
- Use SHA-256 disk-cache filenames to avoid URL-derived filename length limits.

## 2.2.10 - 2026-07-18

### Added

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ let transport = CachingTransport(

For cache survival across launches and standards-based revalidation, use `DiskResponseCache`. Expired entries automatically send `If-None-Match` when an ETag is available; a `304 Not Modified` response reuses the local body and refreshes its TTL.

The cache also honors `Cache-Control: no-store`, supports `Expires`, validates `Vary` request headers before reuse, and keeps the in-memory cache in least-recently-used order.
The cache honors request and response `Cache-Control: no-store`, `no-cache`, and `Expires`; it maintains separate response variants for `Vary` headers and never stores `Vary: *` responses. `304 Not Modified` merges its metadata with the cached response before refreshing TTL. The in-memory cache keeps entries in least-recently-used order.

```swift
let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
Expand Down
2 changes: 1 addition & 1 deletion README.zh-Hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ let transport = CachingTransport(

需要跨 App 启动保留缓存并遵循 HTTP 重新验证语义时,请使用 `DiskResponseCache`。过期条目存在 ETag 时会自动发送 `If-None-Match`;服务端返回 `304 Not Modified` 后会复用本地 body 并刷新 TTL。

缓存同时遵守 `Cache-Control: no-store`、支持 `Expires`,复用前会校验 `Vary` 请求 Header,内存缓存则采用最近最少使用(LRU)淘汰顺序。
缓存遵守请求和响应中的 `Cache-Control: no-store`、`no-cache`,并支持 `Expires`;会为 `Vary` Header 保留独立响应变体,且永不缓存 `Vary: *` 响应。`304 Not Modified` 会先与旧缓存 Header 合并再刷新 TTL,内存缓存则采用最近最少使用(LRU)淘汰顺序。

```swift
let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
Expand Down
130 changes: 104 additions & 26 deletions Sources/NetworkingKit/Core/CachingTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//

import Foundation
import CryptoKit

/// Controls how a `CachingTransport` resolves GET requests.
public enum NetworkCachePolicy: Sendable, Equatable {
Expand All @@ -21,9 +22,18 @@ public enum NetworkCachePolicy: Sendable, Equatable {
/// Stores cached transport responses.
public protocol NetworkResponseCaching: Sendable {
func entry(for key: String) async -> CachedHTTPResponse?
/// Returns every cached response variant for a base request key.
func entries(for key: String) async -> [CachedHTTPResponse]
func store(_ entry: CachedHTTPResponse, for key: String) async
}

public extension NetworkResponseCaching {
func entries(for key: String) async -> [CachedHTTPResponse] {
guard let entry = await entry(for: key) else { return [] }
return [entry]
}
}

/// A cached HTTP response with its expiry and revalidation metadata.
public struct CachedHTTPResponse: Sendable, Codable {
public let data: Data
Expand All @@ -43,31 +53,56 @@ public struct CachedHTTPResponse: Sendable, Codable {
func matches(_ request: URLRequest) -> Bool {
varyHeaders.allSatisfy { request.value(forHTTPHeaderField: $0.key) == $0.value }
}

func mergingRevalidationHeaders(from response: HTTPURLResponse, defaultTTL: TimeInterval) -> CachedHTTPResponse {
let revalidationHeaders = response.headers
let mergedHeaders = headers.merging(revalidationHeaders) { _, new in new }
return CachedHTTPResponse(
data: data,
url: response.url ?? url,
statusCode: statusCode,
headers: mergedHeaders,
expiresAt: CacheControl.expiry(headers: mergedHeaders, defaultTTL: defaultTTL),
eTag: mergedHeaders.value(forHTTPHeaderField: "ETag"),
varyHeaders: varyHeaders
)
}
}

/// A bounded, actor-backed in-memory response cache.
public actor InMemoryResponseCache: NetworkResponseCaching {
private let capacity: Int
private var values: [String: CachedHTTPResponse] = [:]
private var values: [String: [String: CachedHTTPResponse]] = [:]
private var keys: [String] = []

public init(capacity: Int = 100) { self.capacity = max(1, capacity) }

public func entry(for key: String) async -> CachedHTTPResponse? {
guard let entry = values[key] else { return nil }
keys.removeAll { $0 == key }
keys.append(key)
guard let variants = values[key], let entry = variants.values.first else { return nil }
touch(key)
return entry
}

public func entries(for key: String) async -> [CachedHTTPResponse] {
guard let entries = values[key]?.values else { return [] }
touch(key)
return Array(entries)
}

public func store(_ entry: CachedHTTPResponse, for key: String) async {
if values[key] == nil, keys.count >= capacity, let oldest = keys.first {
values.removeValue(forKey: oldest)
keys.removeFirst()
}
var variants = values[key] ?? [:]
variants[entry.variantIdentifier] = entry
values[key] = variants
touch(key)
}

private func touch(_ key: String) {
keys.removeAll { $0 == key }
keys.append(key)
values[key] = entry
}
}

Expand All @@ -81,17 +116,25 @@ public actor DiskResponseCache: NetworkResponseCaching {
}

public func entry(for key: String) async -> CachedHTTPResponse? {
guard let data = try? Data(contentsOf: fileURL(for: key)) else { return nil }
return try? JSONDecoder().decode(CachedHTTPResponse.self, from: data)
await entries(for: key).last
}

public func entries(for key: String) async -> [CachedHTTPResponse] {
guard let data = try? Data(contentsOf: fileURL(for: key)) else { return [] }
if let entries = try? JSONDecoder().decode([CachedHTTPResponse].self, from: data) { return entries }
return (try? JSONDecoder().decode(CachedHTTPResponse.self, from: data)).map { [$0] } ?? []
}

public func store(_ entry: CachedHTTPResponse, for key: String) async {
guard let data = try? JSONEncoder().encode(entry) else { return }
var entries = await entries(for: key)
entries.removeAll { $0.variantIdentifier == entry.variantIdentifier }
entries.append(entry)
guard let data = try? JSONEncoder().encode(entries) else { return }
try? data.write(to: fileURL(for: key), options: .atomic)
}

private func fileURL(for key: String) -> URL {
let name = Data(key.utf8).base64EncodedString().replacingOccurrences(of: "/", with: "_")
let name = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
return directory.appendingPathComponent(name).appendingPathExtension("json")
}
}
Expand All @@ -112,23 +155,26 @@ public struct CachingTransport: NetworkTransport {

public func send(_ request: URLRequest) async throws -> (Data, URLResponse) {
let key = cacheKey(for: request)
let stored = request.httpMethod == HTTPMethod.get.rawValue ? await cache.entry(for: key) : nil
let cached = stored?.matches(request) == true ? stored : nil
let requestDisallowsStorage = request.value(forHTTPHeaderField: "Cache-Control")?.lowercased().contains("no-store") == true
let cached = request.httpMethod == HTTPMethod.get.rawValue && !requestDisallowsStorage
? await cache.entries(for: key).first(where: { $0.matches(request) })
: nil
if policy == .returnCacheDontLoad, let cached { return (cached.data, cached.makeResponse()) }
guard policy != .returnCacheDontLoad else { throw CacheMissError() }
if policy == .returnCacheElseLoad, let cached, cached.isFresh { return (cached.data, cached.makeResponse()) }
if policy == .returnCacheElseLoad, let cached, cached.isFresh, !CacheControl.requiresRevalidation(cached.headers) { return (cached.data, cached.makeResponse()) }

var request = request
if let eTag = cached?.eTag { request.setValue(eTag, forHTTPHeaderField: "If-None-Match") }
let result = try await upstream.send(request)
if let response = result.1 as? HTTPURLResponse, response.statusCode == 304, let cached {
let refreshed = makeEntry(data: cached.data, response: response, request: request, fallbackURL: cached.url)
let refreshed = cached.mergingRevalidationHeaders(from: response, defaultTTL: defaultTTL)
await cache.store(refreshed, for: key)
return (cached.data, refreshed.makeResponse())
}
if request.httpMethod == HTTPMethod.get.rawValue,
let response = result.1 as? HTTPURLResponse,
NetworkConstants.HTTPStatus.successRange.contains(response.statusCode), !isNoStore(response) {
NetworkConstants.HTTPStatus.successRange.contains(response.statusCode), !requestDisallowsStorage,
!isNoStore(response), !response.variesByAllHeaders {
await cache.store(makeEntry(data: result.0, response: response, request: request, fallbackURL: request.url), for: key)
}
return result
Expand All @@ -142,28 +188,36 @@ public struct CachingTransport: NetworkTransport {
let headers = response.allHeaderFields.reduce(into: [String: String]()) { result, item in
if let key = item.key as? String { result[key] = String(describing: item.value) }
}
let cacheControl = headers.first { $0.key.caseInsensitiveCompare("Cache-Control") == .orderedSame }?.value
let directive = cacheControl?.split(separator: ",").first {
$0.trimmingCharacters(in: .whitespaces).hasPrefix("max-age=")
}
let maxAgeValue = directive?.split(separator: "=").last.map(String.init)
let expiresValue = headers.first { $0.key.caseInsensitiveCompare("Expires") == .orderedSame }?.value
let expires = expiresValue.flatMap { parseHTTPDate($0) }
let maxAge = maxAgeValue.flatMap(TimeInterval.init) ?? expires.map { $0.timeIntervalSinceNow } ?? defaultTTL
let eTag = headers.first { $0.key.caseInsensitiveCompare("ETag") == .orderedSame }?.value
let vary = headers.first { $0.key.caseInsensitiveCompare("Vary") == .orderedSame }?.value
let eTag = headers.value(forHTTPHeaderField: "ETag")
let vary = headers.value(forHTTPHeaderField: "Vary")
let varyHeaders = Dictionary(uniqueKeysWithValues: (vary?.split(separator: ",") ?? []).map { name in
let field = name.trimmingCharacters(in: .whitespaces)
return (field, request.value(forHTTPHeaderField: field) ?? "")
})
return CachedHTTPResponse(data: data, url: response.url ?? fallbackURL!, statusCode: response.statusCode, headers: headers, expiresAt: Date().addingTimeInterval(maxAge), eTag: eTag, varyHeaders: varyHeaders)
return CachedHTTPResponse(data: data, url: response.url ?? fallbackURL!, statusCode: response.statusCode, headers: headers, expiresAt: CacheControl.expiry(headers: headers, defaultTTL: defaultTTL), eTag: eTag, varyHeaders: varyHeaders)
}

private func isNoStore(_ response: HTTPURLResponse) -> Bool {
response.value(forHTTPHeaderField: "Cache-Control")?.lowercased().contains("no-store") == true
}

private func parseHTTPDate(_ value: String) -> Date? {
}

private enum CacheControl {
static func requiresRevalidation(_ headers: [String: String]) -> Bool {
headers.value(forHTTPHeaderField: "Cache-Control")?.lowercased().contains("no-cache") == true
}

static func expiry(headers: [String: String], defaultTTL: TimeInterval) -> Date {
let control = headers.value(forHTTPHeaderField: "Cache-Control")?.lowercased() ?? ""
if control.contains("no-cache") { return Date() }
let maxAge = control.split(separator: ",").first { $0.trimmingCharacters(in: .whitespaces).hasPrefix("max-age=") }
.flatMap { TimeInterval($0.split(separator: "=").last ?? "") }
let expires = headers.value(forHTTPHeaderField: "Expires").flatMap(parseHTTPDate)
return Date().addingTimeInterval(maxAge ?? expires.map { $0.timeIntervalSinceNow } ?? defaultTTL)
}

private static func parseHTTPDate(_ value: String) -> Date? {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
Expand All @@ -172,6 +226,30 @@ public struct CachingTransport: NetworkTransport {
}
}

private extension CachedHTTPResponse {
var variantIdentifier: String {
varyHeaders.sorted { $0.key < $1.key }.map { "\($0.key.lowercased())=\($0.value)" }.joined(separator: "&")
}
}

private extension Dictionary where Key == String, Value == String {
func value(forHTTPHeaderField field: String) -> String? {
first { $0.key.caseInsensitiveCompare(field) == .orderedSame }?.value
}
}

private extension HTTPURLResponse {
var headers: [String: String] {
allHeaderFields.reduce(into: [:]) { result, item in
if let key = item.key as? String { result[key] = String(describing: item.value) }
}
}

var variesByAllHeaders: Bool {
value(forHTTPHeaderField: "Vary")?.split(separator: ",").contains { $0.trimmingCharacters(in: .whitespaces) == "*" } == true
}
}

/// Indicates an offline cache-only request had no matching entry.
public struct CacheMissError: LocalizedError, Sendable {
public init() {}
Expand Down
67 changes: 67 additions & 0 deletions Tests/NetworkingKitTests/NetworkingKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,73 @@ final class NetworkingKitTests: XCTestCase {
XCTAssertEqual(counter.value, 1)
}

func testCachingTransportKeepsSeparateVaryVariants() async throws {
let counter = AttemptCounter()
let upstream = StubTransport { request in
_ = counter.increment()
let language = request.value(forHTTPHeaderField: "Accept-Language") ?? "unknown"
let response = HTTPURLResponse(
url: try! XCTUnwrap(request.url),
statusCode: 200,
httpVersion: nil,
headerFields: ["Cache-Control": "max-age=60", "Vary": "Accept-Language"]
)!
return (response, Data(language.utf8))
}
let transport = CachingTransport(upstream: upstream, cache: InMemoryResponseCache())
let url = URL(string: "https://example.com/greeting")!
var english = URLRequest(url: url)
english.setValue("en", forHTTPHeaderField: "Accept-Language")
var chinese = URLRequest(url: url)
chinese.setValue("zh-Hans", forHTTPHeaderField: "Accept-Language")

_ = try await transport.send(english)
_ = try await transport.send(chinese)
let result = try await transport.send(english)

XCTAssertEqual(String(data: result.0, encoding: .utf8), "en")
XCTAssertEqual(counter.value, 2)
}

func testCachingTransportMerges304HeadersWithCachedResponse() async throws {
let counter = AttemptCounter()
let upstream = StubTransport { request in
let attempt = counter.increment()
let headers = attempt == 1
? ["Cache-Control": "max-age=0", "ETag": "v1", "Content-Type": "application/json"]
: ["Cache-Control": "max-age=60"]
let statusCode = attempt == 1 ? 200 : 304
let response = HTTPURLResponse(url: try! XCTUnwrap(request.url), statusCode: statusCode, httpVersion: nil, headerFields: headers)!
return (response, Data("cached".utf8))
}
let transport = CachingTransport(upstream: upstream, cache: InMemoryResponseCache())
let request = URLRequest(url: URL(string: "https://example.com/revalidate")!)

_ = try await transport.send(request)
let revalidated = try await transport.send(request)
_ = try await transport.send(request)

let response = revalidated.1 as? HTTPURLResponse
XCTAssertEqual(response?.value(forHTTPHeaderField: "Content-Type"), "application/json")
XCTAssertEqual(counter.value, 2)
}

func testCachingTransportDoesNotStoreVaryAllResponses() async throws {
let counter = AttemptCounter()
let upstream = StubTransport { request in
_ = counter.increment()
let response = HTTPURLResponse(url: try! XCTUnwrap(request.url), statusCode: 200, httpVersion: nil, headerFields: ["Vary": "*"])!
return (response, Data())
}
let transport = CachingTransport(upstream: upstream, cache: InMemoryResponseCache())
let request = URLRequest(url: URL(string: "https://example.com/private")!)

_ = try await transport.send(request)
_ = try await transport.send(request)

XCTAssertEqual(counter.value, 2)
}

func testCircuitBreakerAllowsOneHalfOpenRecoveryProbe() async throws {
let breaker = CircuitBreaker(failureThreshold: 1, resetTimeout: 0)

Expand Down
Loading