Files
andros 15eb8acbd0 Network performance: ETag caching, partial Range fetches, progressive timeline streaming
- FeedCache actor: conditional GET with ETag/Last-Modified, returns 304 hits from cache
- PartialFeedFetcher: Range requests for header-only Discover fetches and date-filtered timeline fetches (2 requests instead of 3 per feed)
- TimelineFetcher: AsyncStream-based streaming emits posts as each feed arrives instead of waiting for all feeds
- TimelineViewModel: consumes stream progressively so posts appear immediately on first feed
- Discover uses header-only fetch (16 KB vs full file)
- maxConcurrentDownloads raised from 10 to 20
2026-05-16 10:12:41 +02:00

132 lines
5.0 KiB
Swift

import Foundation
/// Errors that can occur when fetching an Org Social feed.
public enum FeedFetcherError: Error, Sendable, Equatable {
/// The URL provided is not a valid HTTP/HTTPS URL.
case invalidURL
/// The server returned a non-2xx HTTP status code.
case httpError(statusCode: Int)
/// The response body could not be decoded as UTF-8 text.
case decodingError
/// A network-level error occurred (timeout, no connection, etc.).
case networkError(underlying: String)
}
extension FeedFetcherError: LocalizedError {
public var errorDescription: String? {
switch self {
case .invalidURL:
return "The URL is not a valid HTTP or HTTPS address."
case .httpError(let code):
return "The server returned HTTP \(code)."
case .decodingError:
return "The feed content could not be decoded as UTF-8 text."
case .networkError(let message):
return "Network error: \(message)"
}
}
}
/// Fetches the raw content of an Org Social feed from a remote URL.
///
/// Conditional GET (ETag / Last-Modified) is applied automatically on every
/// normal fetch. When the server returns 304 Not Modified the previous body
/// is returned from `FeedCache` without hitting the network payload. Pass
/// `bypassCache: true` to force a fresh download (e.g. right after an upload).
public struct FeedFetcher: Sendable {
private let session: URLSession
private let cache: FeedCache
public init(session: URLSession = .shared, cache: FeedCache = .shared) {
self.session = session
self.cache = cache
}
/// Downloads the raw UTF-8 string content of a `social.org` file.
///
/// - Parameters:
/// - url: The public URL of the `social.org` file.
/// - bypassCache: When `true`, forces a fresh download: the cache entry
/// is invalidated, `Cache-Control: no-cache` is sent, and a cache-busting
/// query parameter is appended. Use right after uploads.
/// - Returns: The raw UTF-8 string content of the feed.
/// - Throws: `FeedFetcherError` if the request fails or the response is invalid.
public func fetch(from url: URL, bypassCache: Bool = false) async throws -> String {
guard url.scheme == "http" || url.scheme == "https" else {
throw FeedFetcherError.invalidURL
}
if bypassCache {
await cache.invalidate(url)
}
let requestURL: URL
if bypassCache {
var comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
var items = comps?.queryItems ?? []
items.append(URLQueryItem(name: "_t", value: "\(Int(Date().timeIntervalSince1970 * 1000))"))
comps?.queryItems = items
requestURL = comps?.url ?? url
} else {
requestURL = url
}
var request = URLRequest(url: requestURL)
request.timeoutInterval = 10
if bypassCache {
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
request.setValue("no-cache", forHTTPHeaderField: "Pragma")
} else {
let (ifNoneMatch, ifModifiedSince) = await cache.conditionalHeaders(for: url)
if let etag = ifNoneMatch {
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
}
if let modified = ifModifiedSince {
request.setValue(modified, forHTTPHeaderField: "If-Modified-Since")
}
}
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
throw FeedFetcherError.networkError(underlying: error.localizedDescription)
}
guard let http = response as? HTTPURLResponse else {
throw FeedFetcherError.networkError(underlying: "No HTTP response received")
}
// 304 Not Modified: the server confirmed our cached copy is still fresh.
if http.statusCode == 304 {
if let cached = await cache.cachedContent(for: url) {
return cached
}
// Cache miss on a 304 is a protocol violation from the server treat
// as a network error so the caller can retry with a fresh request.
throw FeedFetcherError.networkError(underlying: "Received 304 but no cached content available")
}
if !(200..<300).contains(http.statusCode) {
throw FeedFetcherError.httpError(statusCode: http.statusCode)
}
guard let content = String(data: data, encoding: .utf8) else {
throw FeedFetcherError.decodingError
}
if !bypassCache {
let etag = http.value(forHTTPHeaderField: "ETag")
let lastModified = http.value(forHTTPHeaderField: "Last-Modified")
await cache.record(url: url, etag: etag, lastModified: lastModified, content: content)
}
return content
}
}