From ef5aa33b7fd38f3842ee78a8d2c65377d2689eab Mon Sep 17 00:00:00 2001 From: ZachNagengast Date: Wed, 29 Oct 2025 13:41:26 -0700 Subject: [PATCH 1/8] Use cache and static urlsession for metadata requests --- Sources/Hub/HubApi.swift | 166 ++++++++++++++++++++++- Tests/HubTests/HubApiTests.swift | 225 +++++++++++++++++++++++++++++++ 2 files changed, 384 insertions(+), 7 deletions(-) diff --git a/Sources/Hub/HubApi.swift b/Sources/Hub/HubApi.swift index e89dab0a..4ea58d02 100644 --- a/Sources/Hub/HubApi.swift +++ b/Sources/Hub/HubApi.swift @@ -79,6 +79,19 @@ public struct HubApi: Sendable { public typealias RepoType = Hub.RepoType public typealias Repo = Hub.Repo + /// Session actor for metadata requests with redirect handling. + /// + /// Static to share a single URLSession across all HubApi instances, preventing resource + /// exhaustion when many instances are created. Persists for process lifetime. + private static let redirectSession: RedirectSessionActor = .init() + + /// Cache for metadata responses with configurable expiration. + /// + /// Shared cache across all HubApi instances for better hit rates and lower memory usage. + /// Reduces redundant network requests for file metadata. Default TTL is 1 minute (60 seconds). + /// Entries auto-expire based on TTL on next get call for any key. + internal static let metadataCache: MetadataCache = .init(defaultTTL: 1 * 60) + /// Initializes a new Hub API client. /// /// - Parameters: @@ -184,7 +197,7 @@ public extension HubApi { /// - Throws: HubClientError for authentication, network, or HTTP errors func httpGet(for url: URL) async throws -> (Data, HTTPURLResponse) { var request = URLRequest(url: url) - if let hfToken { + if let hfToken, !hfToken.isEmpty { request.setValue("Bearer \(hfToken)", forHTTPHeaderField: "Authorization") } @@ -213,32 +226,44 @@ public extension HubApi { /// Performs an HTTP HEAD request to retrieve metadata without downloading content. /// - /// Allows relative redirects but ignores absolute ones for LFS files. + /// Uses a shared URLSession with custom redirect handling that only allows relative redirects + /// and blocks absolute redirects (important for LFS file security). /// /// - Parameter url: The URL to request /// - Returns: A tuple containing the response data and HTTP response /// - Throws: HubClientError if the page does not exist or is not accessible func httpHead(for url: URL) async throws -> (Data, HTTPURLResponse) { + // Create cache key that includes URL and auth status (empty string treated as no auth) + let hasAuth = hfToken.map { !$0.isEmpty } ?? false + let cacheKey = MetadataCacheKey(url: url, hasAuth: hasAuth) + + // Check cache first + if let cachedResponse = await Self.metadataCache.get(cacheKey) { + return (Data(), cachedResponse) + } + var request = URLRequest(url: url) request.httpMethod = "HEAD" - if let hfToken { + if let hfToken, !hfToken.isEmpty { request.setValue("Bearer \(hfToken)", forHTTPHeaderField: "Authorization") } request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") - let redirectDelegate = RedirectDelegate() - let session = URLSession(configuration: .default, delegate: redirectDelegate, delegateQueue: nil) - + // Use shared session with redirect handling to avoid creating multiple URLSession instances + let session = await Self.redirectSession.get() let (data, response) = try await session.data(for: request) guard let response = response as? HTTPURLResponse else { throw Hub.HubClientError.unexpectedError } switch response.statusCode { - case 200..<400: break // Allow redirects to pass through to the redirect delegate + case 200..<400: break // Success and redirects handled by delegate case 401, 403: throw Hub.HubClientError.authorizationRequired case 404: throw Hub.HubClientError.fileNotFound(url.lastPathComponent) default: throw Hub.HubClientError.httpStatusCode(response.statusCode) } + // Cache successful response + await Self.metadataCache.set(cacheKey, response: response) + return (data, response) } @@ -1064,3 +1089,130 @@ private final class RedirectDelegate: NSObject, URLSessionTaskDelegate, Sendable completionHandler(nil) } } + +/// Actor to manage shared URLSession for redirect handling. +/// +/// Lazily initializes and reuses a single URLSession across all HubApi instances +/// to avoid resource exhaustion when running multiple tests or creating many instances. +private actor RedirectSessionActor { + private var urlSession: URLSession? + + func get() -> URLSession { + if let urlSession = urlSession { + return urlSession + } + + // Create session once and reuse + let redirectDelegate = RedirectDelegate() + let session = URLSession(configuration: .default, delegate: redirectDelegate, delegateQueue: nil) + self.urlSession = session + return session + } +} + +/// Cache key for HEAD metadata requests. +/// +/// Includes URL and auth status to ensure cache correctness when +/// switching between authenticated and unauthenticated requests. +internal struct MetadataCacheKey: Hashable { + let url: URL + let hasAuth: Bool +} + +/// Actor-based in-memory cache for HEAD metadata responses with automatic expiration. +/// +/// Reduces redundant HTTP HEAD requests by caching only the `HTTPURLResponse` (headers, status code) +/// with configurable TTL. The response body data is not cached since HEAD requests typically return +/// minimal or no body content - all useful information is in the headers. +/// +/// Uses `ContinuousClock` for monotonic time that's unaffected by system time changes. +/// +/// This cache is distinct from the filesystem metadata stored by `writeDownloadMetadata()`. +/// This in-memory cache is used for `remoteMetadata` and simply avoids +/// redundant network requests during a session. +/// +/// Reference: https://github.com/swiftlang/swift-package-manager/blob/04b0249cf3aca928f6f4aed46cb412cf5696d7fd/Sources/PackageRegistry/RegistryClient.swift#L58-L61. +internal actor MetadataCache { + private var cache: [MetadataCacheKey: CachedMetadata] = [:] + private let clock = ContinuousClock() + + /// Default time-to-live in seconds for cached entries. + private let defaultTTL: Duration + + /// Cache entry with expiration time. + private struct CachedMetadata { + let response: HTTPURLResponse + let expiresAt: ContinuousClock.Instant + } + + /// Initializes a new metadata cache. + /// + /// - Parameter defaultTTL: Default time-to-live for cached entries in seconds (default: 300 seconds / 1 minute) + init(defaultTTL: TimeInterval = 1 * 60) { + self.defaultTTL = .seconds(defaultTTL) + } + + /// Get cached response if it exists and hasn't expired. + /// + /// - Parameter key: The cache key to lookup + /// - Returns: Cached HTTP response if found and not expired, nil otherwise + func get(_ key: MetadataCacheKey) -> HTTPURLResponse? { + // Clean up expired entries + clearExpired() + + guard let cached = cache[key] else { + return nil + } + + // Check if expired + let now = clock.now + if cached.expiresAt < now { + cache.removeValue(forKey: key) + return nil + } + + return cached.response + } + + /// Set cached response with optional custom expiration. + /// + /// - Parameters: + /// - key: The cache key + /// - response: The HTTP response to cache + /// - ttl: Optional custom duration until expiration (uses defaultTTL if nil) + func set(_ key: MetadataCacheKey, response: HTTPURLResponse, ttl: Duration? = nil) { + let ttl = ttl ?? defaultTTL + let expiresAt = clock.now.advanced(by: ttl) + cache[key] = CachedMetadata( + response: response, + expiresAt: expiresAt + ) + } + + /// Check if a key exists in the cache and hasn't expired. + /// + /// - Parameter key: The cache key to check + /// - Returns: True if the key exists and is not expired, false otherwise + func contains(_ key: MetadataCacheKey) -> Bool { + guard let cached = cache[key] else { + return false + } + return cached.expiresAt >= clock.now + } + + /// Get the number of entries currently in the cache. + var count: Int { + cache.count + } + + /// Remove expired entries from cache. + private func clearExpired() { + let now = clock.now + cache = cache.filter { $0.value.expiresAt >= now } + } + + /// Clear all cached entries. + func clear() { + cache.removeAll() + } +} diff --git a/Tests/HubTests/HubApiTests.swift b/Tests/HubTests/HubApiTests.swift index e8c4031e..f4ab2259 100644 --- a/Tests/HubTests/HubApiTests.swift +++ b/Tests/HubTests/HubApiTests.swift @@ -171,6 +171,231 @@ class HubApiTests: XCTestCase { XCTFail("\(error)") } } + + // MARK: Metadata Cache Tests + + func testMetadataCacheHit() async { + do { + let hub = HubApi() + let testUrl = URL(string: "https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/config.json")! + let cacheKey = MetadataCacheKey(url: testUrl, hasAuth: hub.hfToken != nil) + + // Clear cache to ensure a clean state for this test + await HubApi.metadataCache.clear() + + // Verify cache is initially empty for this key + let initiallyIsCached = await HubApi.metadataCache.contains(cacheKey) + XCTAssertFalse(initiallyIsCached, "Cache should be empty before first request") + + // First call should miss cache and hit network + let (_, response1) = try await hub.httpHead(for: testUrl) + XCTAssertNotNil(response1) + + // Verify cache now contains the entry + let isCached = await HubApi.metadataCache.contains(cacheKey) + XCTAssertTrue(isCached, "Cache should contain entry after first request") + + // Second call should hit cache (same URL, same auth state) + let (_, response2) = try await hub.httpHead(for: testUrl) + XCTAssertNotNil(response2) + + // Responses should have same etag + XCTAssertEqual( + response1.value(forHTTPHeaderField: "Etag"), + response2.value(forHTTPHeaderField: "Etag") + ) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testMetadataCacheDifferentUrls() async { + do { + let hub = HubApi() + let url1 = URL(string: "https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/config.json")! + let url2 = URL(string: "https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/tokenizer.json")! + let cacheKey1 = MetadataCacheKey(url: url1, hasAuth: hub.hfToken != nil) + let cacheKey2 = MetadataCacheKey(url: url2, hasAuth: hub.hfToken != nil) + + // Clear cache to ensure a clean state for this test + await HubApi.metadataCache.clear() + + // Cache should be separate for different URLs + let (_, response1) = try await hub.httpHead(for: url1) + let (_, response2) = try await hub.httpHead(for: url2) + + XCTAssertNotNil(response1) + XCTAssertNotNil(response2) + + // Verify both entries are in cache + let key1IsCached = await HubApi.metadataCache.contains(cacheKey1) + let key2IsCached = await HubApi.metadataCache.contains(cacheKey2) + XCTAssertTrue(key1IsCached, "First URL should be cached") + XCTAssertTrue(key2IsCached, "Second URL should be cached") + + // Cache should contain 2 entries + let finalCount = await HubApi.metadataCache.count + XCTAssertEqual(finalCount, 2, "Cache should contain 2 entries") + + // Different URLs should have different responses + XCTAssertNotEqual(response1.url, response2.url) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testMetadataCacheAuthDifferentiation() async { + do { + let testUrl = URL(string: "https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/config.json")! + + // Clear cache to ensure a clean state for this test + await HubApi.metadataCache.clear() + + // Create cache keys - one without auth, one with auth + let cacheKeyNoAuth = MetadataCacheKey(url: testUrl, hasAuth: false) + let cacheKeyWithAuth = MetadataCacheKey(url: testUrl, hasAuth: true) + + // Verify cache keys are different + XCTAssertNotEqual(cacheKeyNoAuth, cacheKeyWithAuth, "Cache keys should differ by auth status") + + // Make a real request to get a valid HTTPURLResponse + let hub = HubApi(hfToken: "") + let (_, response) = try await hub.httpHead(for: testUrl) + XCTAssertNotNil(response) + XCTAssertEqual(response.statusCode, 200) + + // Manually set the same response in cache with different auth states + await HubApi.metadataCache.set(cacheKeyNoAuth, response: response) + await HubApi.metadataCache.set(cacheKeyWithAuth, response: response) + + // Verify both entries exist in cache as separate entries + let noAuthIsCached = await HubApi.metadataCache.contains(cacheKeyNoAuth) + let withAuthIsCached = await HubApi.metadataCache.contains(cacheKeyWithAuth) + XCTAssertTrue(noAuthIsCached, "No-auth entry should be cached") + XCTAssertTrue(withAuthIsCached, "Auth entry should be cached separately") + + // Verify both entries exist in cache (2 separate cache entries) + let cacheCount = await HubApi.metadataCache.count + XCTAssertEqual(cacheCount, 2, "Cache should have 2 separate entries for different auth states") + + // Verify we can retrieve both entries + let retrievedNoAuth = await HubApi.metadataCache.get(cacheKeyNoAuth) + let retrievedWithAuth = await HubApi.metadataCache.get(cacheKeyWithAuth) + XCTAssertNotNil(retrievedNoAuth, "Should retrieve no-auth entry") + XCTAssertNotNil(retrievedWithAuth, "Should retrieve auth entry") + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testGetFileMetadataUsesCache() async { + do { + let hub = HubApi() + let testUrl = URL(string: "https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/config.json")! + let cacheKey = MetadataCacheKey(url: testUrl, hasAuth: hub.hfToken != nil) + + // Clear cache to ensure a clean state for this test + await HubApi.metadataCache.clear() + let initiallyIsCached = await HubApi.metadataCache.contains(cacheKey) + XCTAssertFalse(initiallyIsCached) + + // First call + let metadata1 = try await hub.getFileMetadata(url: testUrl) + XCTAssertNotNil(metadata1.etag) + XCTAssertNotNil(metadata1.size) + + let isCached = await HubApi.metadataCache.contains(cacheKey) + XCTAssertTrue(isCached) + + // Second call should use cache + let metadata2 = try await hub.getFileMetadata(url: testUrl) + XCTAssertEqual(metadata1.etag, metadata2.etag) + XCTAssertEqual(metadata1.size, metadata2.size) + XCTAssertEqual(metadata1.location, metadata2.location) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testMultipleRequestsReduceNetworkCalls() async { + do { + let hub = HubApi() + let testUrl = URL(string: "https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/config.json")! + let cacheKey = MetadataCacheKey(url: testUrl, hasAuth: hub.hfToken != nil) + + // Clear cache to ensure a clean state for this test + await HubApi.metadataCache.clear() + let initiallyIsCached = await HubApi.metadataCache.contains(cacheKey) + XCTAssertFalse(initiallyIsCached) + + // Make multiple requests in quick succession + // Only the first should hit the network, rest should be cached + let results = try await withThrowingTaskGroup(of: HTTPURLResponse.self) { group in + for _ in 0..<10 { + group.addTask { + let (_, response) = try await hub.httpHead(for: testUrl) + return response + } + } + + var responses: [HTTPURLResponse] = [] + for try await response in group { + responses.append(response) + } + return responses + } + + XCTAssertEqual(results.count, 10) + + let isCached = await HubApi.metadataCache.contains(cacheKey) + XCTAssertTrue(isCached) + + let cacheCount = await HubApi.metadataCache.count + XCTAssertEqual(cacheCount, 1) + + // All responses should have the same etag for the same file + let etags = results.compactMap { $0.value(forHTTPHeaderField: "Etag") } + let uniqueEtags = Set(etags) + XCTAssertEqual(uniqueEtags.count, 1, "All cached responses should have the same etag") + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testMetadataCacheExpiration() async { + do { + let hub = HubApi() + let testUrl = URL(string: "https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/config.json")! + let cacheKey = MetadataCacheKey(url: testUrl, hasAuth: hub.hfToken != nil) + + // Clear cache to ensure a clean state + await HubApi.metadataCache.clear() + + // Make a request to populate cache + let (_, response) = try await hub.httpHead(for: testUrl) + XCTAssertNotNil(response) + + // Manually set cache entry with very short TTL (100ms) + await HubApi.metadataCache.set(cacheKey, response: response, ttl: .milliseconds(100)) + + // Verify entry is in cache immediately + let isCachedBeforeExpiry = await HubApi.metadataCache.contains(cacheKey) + XCTAssertTrue(isCachedBeforeExpiry, "Entry should be in cache before expiration") + + // Wait for expiration (150ms to be safe) + try await Task.sleep(for: .milliseconds(150)) + + // Verify entry is no longer in cache after expiration + let isCachedAfterExpiry = await HubApi.metadataCache.contains(cacheKey) + XCTAssertFalse(isCachedAfterExpiry, "Entry should be expired and removed from cache") + + // Verify get() also returns nil for expired entry + let cachedResponse = await HubApi.metadataCache.get(cacheKey) + XCTAssertNil(cachedResponse, "get() should return nil for expired entry") + } catch { + XCTFail("Unexpected error: \(error)") + } + } } class SnapshotDownloadTests: XCTestCase { From cb1e06cf3fe1322c2e2eadc60001195472875eda Mon Sep 17 00:00:00 2001 From: ZachNagengast Date: Wed, 29 Oct 2025 13:42:06 -0700 Subject: [PATCH 2/8] Formatting --- Sources/Hub/HubApi.swift | 20 ++++---- Tests/HubTests/HubApiTests.swift | 84 ++++++++++++++++---------------- 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/Sources/Hub/HubApi.swift b/Sources/Hub/HubApi.swift index 4ea58d02..82c2a2ea 100644 --- a/Sources/Hub/HubApi.swift +++ b/Sources/Hub/HubApi.swift @@ -236,12 +236,12 @@ public extension HubApi { // Create cache key that includes URL and auth status (empty string treated as no auth) let hasAuth = hfToken.map { !$0.isEmpty } ?? false let cacheKey = MetadataCacheKey(url: url, hasAuth: hasAuth) - + // Check cache first if let cachedResponse = await Self.metadataCache.get(cacheKey) { return (Data(), cachedResponse) } - + var request = URLRequest(url: url) request.httpMethod = "HEAD" if let hfToken, !hfToken.isEmpty { @@ -1096,12 +1096,12 @@ private final class RedirectDelegate: NSObject, URLSessionTaskDelegate, Sendable /// to avoid resource exhaustion when running multiple tests or creating many instances. private actor RedirectSessionActor { private var urlSession: URLSession? - + func get() -> URLSession { if let urlSession = urlSession { return urlSession } - + // Create session once and reuse let redirectDelegate = RedirectDelegate() let session = URLSession(configuration: .default, delegate: redirectDelegate, delegateQueue: nil) @@ -1135,7 +1135,7 @@ internal struct MetadataCacheKey: Hashable { internal actor MetadataCache { private var cache: [MetadataCacheKey: CachedMetadata] = [:] private let clock = ContinuousClock() - + /// Default time-to-live in seconds for cached entries. private let defaultTTL: Duration @@ -1159,21 +1159,21 @@ internal actor MetadataCache { func get(_ key: MetadataCacheKey) -> HTTPURLResponse? { // Clean up expired entries clearExpired() - + guard let cached = cache[key] else { return nil } - + // Check if expired let now = clock.now if cached.expiresAt < now { cache.removeValue(forKey: key) return nil } - + return cached.response } - + /// Set cached response with optional custom expiration. /// /// - Parameters: @@ -1199,7 +1199,7 @@ internal actor MetadataCache { } return cached.expiresAt >= clock.now } - + /// Get the number of entries currently in the cache. var count: Int { cache.count diff --git a/Tests/HubTests/HubApiTests.swift b/Tests/HubTests/HubApiTests.swift index f4ab2259..5208f2f1 100644 --- a/Tests/HubTests/HubApiTests.swift +++ b/Tests/HubTests/HubApiTests.swift @@ -171,34 +171,34 @@ class HubApiTests: XCTestCase { XCTFail("\(error)") } } - + // MARK: Metadata Cache Tests - + func testMetadataCacheHit() async { do { let hub = HubApi() let testUrl = URL(string: "https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/config.json")! let cacheKey = MetadataCacheKey(url: testUrl, hasAuth: hub.hfToken != nil) - + // Clear cache to ensure a clean state for this test await HubApi.metadataCache.clear() - + // Verify cache is initially empty for this key let initiallyIsCached = await HubApi.metadataCache.contains(cacheKey) XCTAssertFalse(initiallyIsCached, "Cache should be empty before first request") - + // First call should miss cache and hit network let (_, response1) = try await hub.httpHead(for: testUrl) XCTAssertNotNil(response1) - + // Verify cache now contains the entry let isCached = await HubApi.metadataCache.contains(cacheKey) XCTAssertTrue(isCached, "Cache should contain entry after first request") - + // Second call should hit cache (same URL, same auth state) let (_, response2) = try await hub.httpHead(for: testUrl) XCTAssertNotNil(response2) - + // Responses should have same etag XCTAssertEqual( response1.value(forHTTPHeaderField: "Etag"), @@ -208,7 +208,7 @@ class HubApiTests: XCTestCase { XCTFail("Unexpected error: \(error)") } } - + func testMetadataCacheDifferentUrls() async { do { let hub = HubApi() @@ -216,68 +216,68 @@ class HubApiTests: XCTestCase { let url2 = URL(string: "https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/tokenizer.json")! let cacheKey1 = MetadataCacheKey(url: url1, hasAuth: hub.hfToken != nil) let cacheKey2 = MetadataCacheKey(url: url2, hasAuth: hub.hfToken != nil) - + // Clear cache to ensure a clean state for this test await HubApi.metadataCache.clear() - + // Cache should be separate for different URLs let (_, response1) = try await hub.httpHead(for: url1) let (_, response2) = try await hub.httpHead(for: url2) - + XCTAssertNotNil(response1) XCTAssertNotNil(response2) - + // Verify both entries are in cache let key1IsCached = await HubApi.metadataCache.contains(cacheKey1) let key2IsCached = await HubApi.metadataCache.contains(cacheKey2) XCTAssertTrue(key1IsCached, "First URL should be cached") XCTAssertTrue(key2IsCached, "Second URL should be cached") - + // Cache should contain 2 entries let finalCount = await HubApi.metadataCache.count XCTAssertEqual(finalCount, 2, "Cache should contain 2 entries") - + // Different URLs should have different responses XCTAssertNotEqual(response1.url, response2.url) } catch { XCTFail("Unexpected error: \(error)") } } - + func testMetadataCacheAuthDifferentiation() async { do { let testUrl = URL(string: "https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/config.json")! - + // Clear cache to ensure a clean state for this test await HubApi.metadataCache.clear() - + // Create cache keys - one without auth, one with auth let cacheKeyNoAuth = MetadataCacheKey(url: testUrl, hasAuth: false) let cacheKeyWithAuth = MetadataCacheKey(url: testUrl, hasAuth: true) - + // Verify cache keys are different XCTAssertNotEqual(cacheKeyNoAuth, cacheKeyWithAuth, "Cache keys should differ by auth status") - + // Make a real request to get a valid HTTPURLResponse let hub = HubApi(hfToken: "") let (_, response) = try await hub.httpHead(for: testUrl) XCTAssertNotNil(response) XCTAssertEqual(response.statusCode, 200) - + // Manually set the same response in cache with different auth states await HubApi.metadataCache.set(cacheKeyNoAuth, response: response) await HubApi.metadataCache.set(cacheKeyWithAuth, response: response) - + // Verify both entries exist in cache as separate entries let noAuthIsCached = await HubApi.metadataCache.contains(cacheKeyNoAuth) let withAuthIsCached = await HubApi.metadataCache.contains(cacheKeyWithAuth) XCTAssertTrue(noAuthIsCached, "No-auth entry should be cached") XCTAssertTrue(withAuthIsCached, "Auth entry should be cached separately") - + // Verify both entries exist in cache (2 separate cache entries) let cacheCount = await HubApi.metadataCache.count XCTAssertEqual(cacheCount, 2, "Cache should have 2 separate entries for different auth states") - + // Verify we can retrieve both entries let retrievedNoAuth = await HubApi.metadataCache.get(cacheKeyNoAuth) let retrievedWithAuth = await HubApi.metadataCache.get(cacheKeyWithAuth) @@ -287,7 +287,7 @@ class HubApiTests: XCTestCase { XCTFail("Unexpected error: \(error)") } } - + func testGetFileMetadataUsesCache() async { do { let hub = HubApi() @@ -298,15 +298,15 @@ class HubApiTests: XCTestCase { await HubApi.metadataCache.clear() let initiallyIsCached = await HubApi.metadataCache.contains(cacheKey) XCTAssertFalse(initiallyIsCached) - + // First call let metadata1 = try await hub.getFileMetadata(url: testUrl) XCTAssertNotNil(metadata1.etag) XCTAssertNotNil(metadata1.size) - + let isCached = await HubApi.metadataCache.contains(cacheKey) XCTAssertTrue(isCached) - + // Second call should use cache let metadata2 = try await hub.getFileMetadata(url: testUrl) XCTAssertEqual(metadata1.etag, metadata2.etag) @@ -316,7 +316,7 @@ class HubApiTests: XCTestCase { XCTFail("Unexpected error: \(error)") } } - + func testMultipleRequestsReduceNetworkCalls() async { do { let hub = HubApi() @@ -337,22 +337,22 @@ class HubApiTests: XCTestCase { return response } } - + var responses: [HTTPURLResponse] = [] for try await response in group { responses.append(response) } return responses } - + XCTAssertEqual(results.count, 10) - + let isCached = await HubApi.metadataCache.contains(cacheKey) XCTAssertTrue(isCached) - + let cacheCount = await HubApi.metadataCache.count XCTAssertEqual(cacheCount, 1) - + // All responses should have the same etag for the same file let etags = results.compactMap { $0.value(forHTTPHeaderField: "Etag") } let uniqueEtags = Set(etags) @@ -361,34 +361,34 @@ class HubApiTests: XCTestCase { XCTFail("Unexpected error: \(error)") } } - + func testMetadataCacheExpiration() async { do { let hub = HubApi() let testUrl = URL(string: "https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/config.json")! let cacheKey = MetadataCacheKey(url: testUrl, hasAuth: hub.hfToken != nil) - + // Clear cache to ensure a clean state await HubApi.metadataCache.clear() - + // Make a request to populate cache let (_, response) = try await hub.httpHead(for: testUrl) XCTAssertNotNil(response) - + // Manually set cache entry with very short TTL (100ms) await HubApi.metadataCache.set(cacheKey, response: response, ttl: .milliseconds(100)) - + // Verify entry is in cache immediately let isCachedBeforeExpiry = await HubApi.metadataCache.contains(cacheKey) XCTAssertTrue(isCachedBeforeExpiry, "Entry should be in cache before expiration") - + // Wait for expiration (150ms to be safe) try await Task.sleep(for: .milliseconds(150)) - + // Verify entry is no longer in cache after expiration let isCachedAfterExpiry = await HubApi.metadataCache.contains(cacheKey) XCTAssertFalse(isCachedAfterExpiry, "Entry should be expired and removed from cache") - + // Verify get() also returns nil for expired entry let cachedResponse = await HubApi.metadataCache.get(cacheKey) XCTAssertNil(cachedResponse, "get() should return nil for expired entry") From 18ef7a3dbf8bd433577c038e292ec12b3768c44d Mon Sep 17 00:00:00 2001 From: ZachNagengast Date: Thu, 30 Oct 2025 00:53:27 -0700 Subject: [PATCH 3/8] Update .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 934a2ff3..fe9534bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ .DS_Store -/.build +**/.build /.swiftpm Package.resolved /Packages From 60ae68a68f8b9e7fa0b2372aa6e861310d1b5f9f Mon Sep 17 00:00:00 2001 From: ZachNagengast Date: Mon, 3 Nov 2025 11:01:03 -0800 Subject: [PATCH 4/8] Fix comment --- Sources/Hub/HubApi.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Hub/HubApi.swift b/Sources/Hub/HubApi.swift index 82c2a2ea..e849a71f 100644 --- a/Sources/Hub/HubApi.swift +++ b/Sources/Hub/HubApi.swift @@ -1147,7 +1147,7 @@ internal actor MetadataCache { /// Initializes a new metadata cache. /// - /// - Parameter defaultTTL: Default time-to-live for cached entries in seconds (default: 300 seconds / 1 minute) + /// - Parameter defaultTTL: Default time-to-live for cached entries in seconds (default: 60 seconds / 1 minute) init(defaultTTL: TimeInterval = 1 * 60) { self.defaultTTL = .seconds(defaultTTL) } From bac92b316ed7921cd2585e87b6ae51ffbe30e7f9 Mon Sep 17 00:00:00 2001 From: ZachNagengast Date: Mon, 3 Nov 2025 11:56:34 -0800 Subject: [PATCH 5/8] Cleanup httpHead interface --- Sources/Hub/HubApi.swift | 22 +++++++++++----------- Tests/HubTests/HubApiTests.swift | 28 ++++++++++++++-------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/Sources/Hub/HubApi.swift b/Sources/Hub/HubApi.swift index e849a71f..159c9eaf 100644 --- a/Sources/Hub/HubApi.swift +++ b/Sources/Hub/HubApi.swift @@ -230,16 +230,15 @@ public extension HubApi { /// and blocks absolute redirects (important for LFS file security). /// /// - Parameter url: The URL to request - /// - Returns: A tuple containing the response data and HTTP response + /// - Returns: The HTTP response containing headers and status code /// - Throws: HubClientError if the page does not exist or is not accessible - func httpHead(for url: URL) async throws -> (Data, HTTPURLResponse) { - // Create cache key that includes URL and auth status (empty string treated as no auth) - let hasAuth = hfToken.map { !$0.isEmpty } ?? false - let cacheKey = MetadataCacheKey(url: url, hasAuth: hasAuth) + func httpHead(for url: URL) async throws -> HTTPURLResponse { + // Create cache key that includes URL and auth status + let cacheKey = MetadataCacheKey(url: url, hasAuth: hfToken?.isEmpty == false) // Check cache first if let cachedResponse = await Self.metadataCache.get(cacheKey) { - return (Data(), cachedResponse) + return cachedResponse } var request = URLRequest(url: url) @@ -251,11 +250,11 @@ public extension HubApi { // Use shared session with redirect handling to avoid creating multiple URLSession instances let session = await Self.redirectSession.get() - let (data, response) = try await session.data(for: request) + let (_, response) = try await session.data(for: request) guard let response = response as? HTTPURLResponse else { throw Hub.HubClientError.unexpectedError } switch response.statusCode { - case 200..<400: break // Success and redirects handled by delegate + case 200..<400: break // Allow redirects to pass through to the redirect delegate case 401, 403: throw Hub.HubClientError.authorizationRequired case 404: throw Hub.HubClientError.fileNotFound(url.lastPathComponent) default: throw Hub.HubClientError.httpStatusCode(response.statusCode) @@ -264,7 +263,7 @@ public extension HubApi { // Cache successful response await Self.metadataCache.set(cacheKey, response: response) - return (data, response) + return response } /// Retrieves the list of filenames in a repository that match the specified glob patterns. @@ -755,7 +754,7 @@ public extension HubApi { } func getFileMetadata(url: URL) async throws -> FileMetadata { - let (_, response) = try await httpHead(for: url) + let response = try await httpHead(for: url) let location = response.statusCode == 302 ? response.value(forHTTPHeaderField: "Location") : response.url?.absoluteString return FileMetadata( @@ -1202,7 +1201,8 @@ internal actor MetadataCache { /// Get the number of entries currently in the cache. var count: Int { - cache.count + clearExpired() + return cache.count } /// Remove expired entries from cache. diff --git a/Tests/HubTests/HubApiTests.swift b/Tests/HubTests/HubApiTests.swift index 5208f2f1..e63115d3 100644 --- a/Tests/HubTests/HubApiTests.swift +++ b/Tests/HubTests/HubApiTests.swift @@ -187,17 +187,17 @@ class HubApiTests: XCTestCase { let initiallyIsCached = await HubApi.metadataCache.contains(cacheKey) XCTAssertFalse(initiallyIsCached, "Cache should be empty before first request") - // First call should miss cache and hit network - let (_, response1) = try await hub.httpHead(for: testUrl) - XCTAssertNotNil(response1) + // First call should miss cache and hit network + let response1 = try await hub.httpHead(for: testUrl) + XCTAssertNotNil(response1) - // Verify cache now contains the entry - let isCached = await HubApi.metadataCache.contains(cacheKey) - XCTAssertTrue(isCached, "Cache should contain entry after first request") + // Verify cache now contains the entry + let isCached = await HubApi.metadataCache.contains(cacheKey) + XCTAssertTrue(isCached, "Cache should contain entry after first request") - // Second call should hit cache (same URL, same auth state) - let (_, response2) = try await hub.httpHead(for: testUrl) - XCTAssertNotNil(response2) + // Second call should hit cache (same URL, same auth state) + let response2 = try await hub.httpHead(for: testUrl) + XCTAssertNotNil(response2) // Responses should have same etag XCTAssertEqual( @@ -221,8 +221,8 @@ class HubApiTests: XCTestCase { await HubApi.metadataCache.clear() // Cache should be separate for different URLs - let (_, response1) = try await hub.httpHead(for: url1) - let (_, response2) = try await hub.httpHead(for: url2) + let response1 = try await hub.httpHead(for: url1) + let response2 = try await hub.httpHead(for: url2) XCTAssertNotNil(response1) XCTAssertNotNil(response2) @@ -260,7 +260,7 @@ class HubApiTests: XCTestCase { // Make a real request to get a valid HTTPURLResponse let hub = HubApi(hfToken: "") - let (_, response) = try await hub.httpHead(for: testUrl) + let response = try await hub.httpHead(for: testUrl) XCTAssertNotNil(response) XCTAssertEqual(response.statusCode, 200) @@ -333,7 +333,7 @@ class HubApiTests: XCTestCase { let results = try await withThrowingTaskGroup(of: HTTPURLResponse.self) { group in for _ in 0..<10 { group.addTask { - let (_, response) = try await hub.httpHead(for: testUrl) + let response = try await hub.httpHead(for: testUrl) return response } } @@ -372,7 +372,7 @@ class HubApiTests: XCTestCase { await HubApi.metadataCache.clear() // Make a request to populate cache - let (_, response) = try await hub.httpHead(for: testUrl) + let response = try await hub.httpHead(for: testUrl) XCTAssertNotNil(response) // Manually set cache entry with very short TTL (100ms) From 2ad0cb8a45eca44bd90141f7ff14cef3038363bd Mon Sep 17 00:00:00 2001 From: ZachNagengast Date: Mon, 3 Nov 2025 12:34:35 -0800 Subject: [PATCH 6/8] Formatting --- Tests/HubTests/HubApiTests.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Tests/HubTests/HubApiTests.swift b/Tests/HubTests/HubApiTests.swift index e63115d3..5b129ab0 100644 --- a/Tests/HubTests/HubApiTests.swift +++ b/Tests/HubTests/HubApiTests.swift @@ -187,17 +187,17 @@ class HubApiTests: XCTestCase { let initiallyIsCached = await HubApi.metadataCache.contains(cacheKey) XCTAssertFalse(initiallyIsCached, "Cache should be empty before first request") - // First call should miss cache and hit network - let response1 = try await hub.httpHead(for: testUrl) - XCTAssertNotNil(response1) + // First call should miss cache and hit network + let response1 = try await hub.httpHead(for: testUrl) + XCTAssertNotNil(response1) - // Verify cache now contains the entry - let isCached = await HubApi.metadataCache.contains(cacheKey) - XCTAssertTrue(isCached, "Cache should contain entry after first request") + // Verify cache now contains the entry + let isCached = await HubApi.metadataCache.contains(cacheKey) + XCTAssertTrue(isCached, "Cache should contain entry after first request") - // Second call should hit cache (same URL, same auth state) - let response2 = try await hub.httpHead(for: testUrl) - XCTAssertNotNil(response2) + // Second call should hit cache (same URL, same auth state) + let response2 = try await hub.httpHead(for: testUrl) + XCTAssertNotNil(response2) // Responses should have same etag XCTAssertEqual( From 8ca98051058a18e1005c25b687db667c2ff48c1a Mon Sep 17 00:00:00 2001 From: ZachNagengast Date: Tue, 4 Nov 2025 12:30:07 -0800 Subject: [PATCH 7/8] Remove metadata request caching - Better handled with a follow up PR to prevent unintended edge cases --- Sources/Hub/HubApi.swift | 126 ----------------- Tests/HubTests/HubApiTests.swift | 225 ------------------------------- 2 files changed, 351 deletions(-) diff --git a/Sources/Hub/HubApi.swift b/Sources/Hub/HubApi.swift index 159c9eaf..0da9408a 100644 --- a/Sources/Hub/HubApi.swift +++ b/Sources/Hub/HubApi.swift @@ -85,13 +85,6 @@ public struct HubApi: Sendable { /// exhaustion when many instances are created. Persists for process lifetime. private static let redirectSession: RedirectSessionActor = .init() - /// Cache for metadata responses with configurable expiration. - /// - /// Shared cache across all HubApi instances for better hit rates and lower memory usage. - /// Reduces redundant network requests for file metadata. Default TTL is 1 minute (60 seconds). - /// Entries auto-expire based on TTL on next get call for any key. - internal static let metadataCache: MetadataCache = .init(defaultTTL: 1 * 60) - /// Initializes a new Hub API client. /// /// - Parameters: @@ -233,14 +226,6 @@ public extension HubApi { /// - Returns: The HTTP response containing headers and status code /// - Throws: HubClientError if the page does not exist or is not accessible func httpHead(for url: URL) async throws -> HTTPURLResponse { - // Create cache key that includes URL and auth status - let cacheKey = MetadataCacheKey(url: url, hasAuth: hfToken?.isEmpty == false) - - // Check cache first - if let cachedResponse = await Self.metadataCache.get(cacheKey) { - return cachedResponse - } - var request = URLRequest(url: url) request.httpMethod = "HEAD" if let hfToken, !hfToken.isEmpty { @@ -260,9 +245,6 @@ public extension HubApi { default: throw Hub.HubClientError.httpStatusCode(response.statusCode) } - // Cache successful response - await Self.metadataCache.set(cacheKey, response: response) - return response } @@ -1108,111 +1090,3 @@ private actor RedirectSessionActor { return session } } - -/// Cache key for HEAD metadata requests. -/// -/// Includes URL and auth status to ensure cache correctness when -/// switching between authenticated and unauthenticated requests. -internal struct MetadataCacheKey: Hashable { - let url: URL - let hasAuth: Bool -} - -/// Actor-based in-memory cache for HEAD metadata responses with automatic expiration. -/// -/// Reduces redundant HTTP HEAD requests by caching only the `HTTPURLResponse` (headers, status code) -/// with configurable TTL. The response body data is not cached since HEAD requests typically return -/// minimal or no body content - all useful information is in the headers. -/// -/// Uses `ContinuousClock` for monotonic time that's unaffected by system time changes. -/// -/// This cache is distinct from the filesystem metadata stored by `writeDownloadMetadata()`. -/// This in-memory cache is used for `remoteMetadata` and simply avoids -/// redundant network requests during a session. -/// -/// Reference: https://github.com/swiftlang/swift-package-manager/blob/04b0249cf3aca928f6f4aed46cb412cf5696d7fd/Sources/PackageRegistry/RegistryClient.swift#L58-L61. -internal actor MetadataCache { - private var cache: [MetadataCacheKey: CachedMetadata] = [:] - private let clock = ContinuousClock() - - /// Default time-to-live in seconds for cached entries. - private let defaultTTL: Duration - - /// Cache entry with expiration time. - private struct CachedMetadata { - let response: HTTPURLResponse - let expiresAt: ContinuousClock.Instant - } - - /// Initializes a new metadata cache. - /// - /// - Parameter defaultTTL: Default time-to-live for cached entries in seconds (default: 60 seconds / 1 minute) - init(defaultTTL: TimeInterval = 1 * 60) { - self.defaultTTL = .seconds(defaultTTL) - } - - /// Get cached response if it exists and hasn't expired. - /// - /// - Parameter key: The cache key to lookup - /// - Returns: Cached HTTP response if found and not expired, nil otherwise - func get(_ key: MetadataCacheKey) -> HTTPURLResponse? { - // Clean up expired entries - clearExpired() - - guard let cached = cache[key] else { - return nil - } - - // Check if expired - let now = clock.now - if cached.expiresAt < now { - cache.removeValue(forKey: key) - return nil - } - - return cached.response - } - - /// Set cached response with optional custom expiration. - /// - /// - Parameters: - /// - key: The cache key - /// - response: The HTTP response to cache - /// - ttl: Optional custom duration until expiration (uses defaultTTL if nil) - func set(_ key: MetadataCacheKey, response: HTTPURLResponse, ttl: Duration? = nil) { - let ttl = ttl ?? defaultTTL - let expiresAt = clock.now.advanced(by: ttl) - cache[key] = CachedMetadata( - response: response, - expiresAt: expiresAt - ) - } - - /// Check if a key exists in the cache and hasn't expired. - /// - /// - Parameter key: The cache key to check - /// - Returns: True if the key exists and is not expired, false otherwise - func contains(_ key: MetadataCacheKey) -> Bool { - guard let cached = cache[key] else { - return false - } - return cached.expiresAt >= clock.now - } - - /// Get the number of entries currently in the cache. - var count: Int { - clearExpired() - return cache.count - } - - /// Remove expired entries from cache. - private func clearExpired() { - let now = clock.now - cache = cache.filter { $0.value.expiresAt >= now } - } - - /// Clear all cached entries. - func clear() { - cache.removeAll() - } -} diff --git a/Tests/HubTests/HubApiTests.swift b/Tests/HubTests/HubApiTests.swift index 5b129ab0..e8c4031e 100644 --- a/Tests/HubTests/HubApiTests.swift +++ b/Tests/HubTests/HubApiTests.swift @@ -171,231 +171,6 @@ class HubApiTests: XCTestCase { XCTFail("\(error)") } } - - // MARK: Metadata Cache Tests - - func testMetadataCacheHit() async { - do { - let hub = HubApi() - let testUrl = URL(string: "https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/config.json")! - let cacheKey = MetadataCacheKey(url: testUrl, hasAuth: hub.hfToken != nil) - - // Clear cache to ensure a clean state for this test - await HubApi.metadataCache.clear() - - // Verify cache is initially empty for this key - let initiallyIsCached = await HubApi.metadataCache.contains(cacheKey) - XCTAssertFalse(initiallyIsCached, "Cache should be empty before first request") - - // First call should miss cache and hit network - let response1 = try await hub.httpHead(for: testUrl) - XCTAssertNotNil(response1) - - // Verify cache now contains the entry - let isCached = await HubApi.metadataCache.contains(cacheKey) - XCTAssertTrue(isCached, "Cache should contain entry after first request") - - // Second call should hit cache (same URL, same auth state) - let response2 = try await hub.httpHead(for: testUrl) - XCTAssertNotNil(response2) - - // Responses should have same etag - XCTAssertEqual( - response1.value(forHTTPHeaderField: "Etag"), - response2.value(forHTTPHeaderField: "Etag") - ) - } catch { - XCTFail("Unexpected error: \(error)") - } - } - - func testMetadataCacheDifferentUrls() async { - do { - let hub = HubApi() - let url1 = URL(string: "https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/config.json")! - let url2 = URL(string: "https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/tokenizer.json")! - let cacheKey1 = MetadataCacheKey(url: url1, hasAuth: hub.hfToken != nil) - let cacheKey2 = MetadataCacheKey(url: url2, hasAuth: hub.hfToken != nil) - - // Clear cache to ensure a clean state for this test - await HubApi.metadataCache.clear() - - // Cache should be separate for different URLs - let response1 = try await hub.httpHead(for: url1) - let response2 = try await hub.httpHead(for: url2) - - XCTAssertNotNil(response1) - XCTAssertNotNil(response2) - - // Verify both entries are in cache - let key1IsCached = await HubApi.metadataCache.contains(cacheKey1) - let key2IsCached = await HubApi.metadataCache.contains(cacheKey2) - XCTAssertTrue(key1IsCached, "First URL should be cached") - XCTAssertTrue(key2IsCached, "Second URL should be cached") - - // Cache should contain 2 entries - let finalCount = await HubApi.metadataCache.count - XCTAssertEqual(finalCount, 2, "Cache should contain 2 entries") - - // Different URLs should have different responses - XCTAssertNotEqual(response1.url, response2.url) - } catch { - XCTFail("Unexpected error: \(error)") - } - } - - func testMetadataCacheAuthDifferentiation() async { - do { - let testUrl = URL(string: "https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/config.json")! - - // Clear cache to ensure a clean state for this test - await HubApi.metadataCache.clear() - - // Create cache keys - one without auth, one with auth - let cacheKeyNoAuth = MetadataCacheKey(url: testUrl, hasAuth: false) - let cacheKeyWithAuth = MetadataCacheKey(url: testUrl, hasAuth: true) - - // Verify cache keys are different - XCTAssertNotEqual(cacheKeyNoAuth, cacheKeyWithAuth, "Cache keys should differ by auth status") - - // Make a real request to get a valid HTTPURLResponse - let hub = HubApi(hfToken: "") - let response = try await hub.httpHead(for: testUrl) - XCTAssertNotNil(response) - XCTAssertEqual(response.statusCode, 200) - - // Manually set the same response in cache with different auth states - await HubApi.metadataCache.set(cacheKeyNoAuth, response: response) - await HubApi.metadataCache.set(cacheKeyWithAuth, response: response) - - // Verify both entries exist in cache as separate entries - let noAuthIsCached = await HubApi.metadataCache.contains(cacheKeyNoAuth) - let withAuthIsCached = await HubApi.metadataCache.contains(cacheKeyWithAuth) - XCTAssertTrue(noAuthIsCached, "No-auth entry should be cached") - XCTAssertTrue(withAuthIsCached, "Auth entry should be cached separately") - - // Verify both entries exist in cache (2 separate cache entries) - let cacheCount = await HubApi.metadataCache.count - XCTAssertEqual(cacheCount, 2, "Cache should have 2 separate entries for different auth states") - - // Verify we can retrieve both entries - let retrievedNoAuth = await HubApi.metadataCache.get(cacheKeyNoAuth) - let retrievedWithAuth = await HubApi.metadataCache.get(cacheKeyWithAuth) - XCTAssertNotNil(retrievedNoAuth, "Should retrieve no-auth entry") - XCTAssertNotNil(retrievedWithAuth, "Should retrieve auth entry") - } catch { - XCTFail("Unexpected error: \(error)") - } - } - - func testGetFileMetadataUsesCache() async { - do { - let hub = HubApi() - let testUrl = URL(string: "https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/config.json")! - let cacheKey = MetadataCacheKey(url: testUrl, hasAuth: hub.hfToken != nil) - - // Clear cache to ensure a clean state for this test - await HubApi.metadataCache.clear() - let initiallyIsCached = await HubApi.metadataCache.contains(cacheKey) - XCTAssertFalse(initiallyIsCached) - - // First call - let metadata1 = try await hub.getFileMetadata(url: testUrl) - XCTAssertNotNil(metadata1.etag) - XCTAssertNotNil(metadata1.size) - - let isCached = await HubApi.metadataCache.contains(cacheKey) - XCTAssertTrue(isCached) - - // Second call should use cache - let metadata2 = try await hub.getFileMetadata(url: testUrl) - XCTAssertEqual(metadata1.etag, metadata2.etag) - XCTAssertEqual(metadata1.size, metadata2.size) - XCTAssertEqual(metadata1.location, metadata2.location) - } catch { - XCTFail("Unexpected error: \(error)") - } - } - - func testMultipleRequestsReduceNetworkCalls() async { - do { - let hub = HubApi() - let testUrl = URL(string: "https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/config.json")! - let cacheKey = MetadataCacheKey(url: testUrl, hasAuth: hub.hfToken != nil) - - // Clear cache to ensure a clean state for this test - await HubApi.metadataCache.clear() - let initiallyIsCached = await HubApi.metadataCache.contains(cacheKey) - XCTAssertFalse(initiallyIsCached) - - // Make multiple requests in quick succession - // Only the first should hit the network, rest should be cached - let results = try await withThrowingTaskGroup(of: HTTPURLResponse.self) { group in - for _ in 0..<10 { - group.addTask { - let response = try await hub.httpHead(for: testUrl) - return response - } - } - - var responses: [HTTPURLResponse] = [] - for try await response in group { - responses.append(response) - } - return responses - } - - XCTAssertEqual(results.count, 10) - - let isCached = await HubApi.metadataCache.contains(cacheKey) - XCTAssertTrue(isCached) - - let cacheCount = await HubApi.metadataCache.count - XCTAssertEqual(cacheCount, 1) - - // All responses should have the same etag for the same file - let etags = results.compactMap { $0.value(forHTTPHeaderField: "Etag") } - let uniqueEtags = Set(etags) - XCTAssertEqual(uniqueEtags.count, 1, "All cached responses should have the same etag") - } catch { - XCTFail("Unexpected error: \(error)") - } - } - - func testMetadataCacheExpiration() async { - do { - let hub = HubApi() - let testUrl = URL(string: "https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/config.json")! - let cacheKey = MetadataCacheKey(url: testUrl, hasAuth: hub.hfToken != nil) - - // Clear cache to ensure a clean state - await HubApi.metadataCache.clear() - - // Make a request to populate cache - let response = try await hub.httpHead(for: testUrl) - XCTAssertNotNil(response) - - // Manually set cache entry with very short TTL (100ms) - await HubApi.metadataCache.set(cacheKey, response: response, ttl: .milliseconds(100)) - - // Verify entry is in cache immediately - let isCachedBeforeExpiry = await HubApi.metadataCache.contains(cacheKey) - XCTAssertTrue(isCachedBeforeExpiry, "Entry should be in cache before expiration") - - // Wait for expiration (150ms to be safe) - try await Task.sleep(for: .milliseconds(150)) - - // Verify entry is no longer in cache after expiration - let isCachedAfterExpiry = await HubApi.metadataCache.contains(cacheKey) - XCTAssertFalse(isCachedAfterExpiry, "Entry should be expired and removed from cache") - - // Verify get() also returns nil for expired entry - let cachedResponse = await HubApi.metadataCache.get(cacheKey) - XCTAssertNil(cachedResponse, "get() should return nil for expired entry") - } catch { - XCTFail("Unexpected error: \(error)") - } - } } class SnapshotDownloadTests: XCTestCase { From 639bd6033a7935bf6a4d419d9a874d7df9510456 Mon Sep 17 00:00:00 2001 From: Pedro Cuenca Date: Wed, 5 Nov 2025 15:30:42 +0100 Subject: [PATCH 8/8] Update doc comment --- Sources/Hub/HubApi.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Hub/HubApi.swift b/Sources/Hub/HubApi.swift index 0da9408a..735114e0 100644 --- a/Sources/Hub/HubApi.swift +++ b/Sources/Hub/HubApi.swift @@ -79,7 +79,7 @@ public struct HubApi: Sendable { public typealias RepoType = Hub.RepoType public typealias Repo = Hub.Repo - /// Session actor for metadata requests with redirect handling. + /// Session actor for metadata requests with relative redirect handling (used in HEAD requests). /// /// Static to share a single URLSession across all HubApi instances, preventing resource /// exhaustion when many instances are created. Persists for process lifetime.