URLSession With a Budget, a Refusal, and a Deadline
~17 min · engine-at-home, urlsession, networking, performance, swift-concurrency
Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"A slow-but-correct loop is invisible to a suite that never runs it."
What a Client Session Should Refuse
A thin client talks to exactly one engine, so its URLSession should carry nothing it did not ask for. Start from URLSessionConfiguration.ephemeral, which keeps cookies, cache and credentials in memory only, and set httpCookieStorage and urlCache to nil so there is nothing to keep. Set both deadlines on purpose: timeoutIntervalForRequest is the longest silence allowed between bytes, and timeoutIntervalForResource bounds the whole exchange.
Redirects are the refusal a configuration cannot express. An engine never answers with a 3xx on purpose, so a redirect means something between the client and the engine is not what it claims to be. The family's shared transport refuses them in the delegate method urlSession(_:task:willPerformHTTPRedirection:newRequest:completionHandler:) by passing nil, which hands back the 3xx response itself. Measured in the kit: a caller that used only the hardened configuration, without that delegate, followed the redirect and received the target's 200 and body. A session created with a delegate holds that delegate strongly until the session is invalidated, so a per-request session must call finishTasksAndInvalidate().
A Budget Enforced While Reading
An engine reply can be a few kilobytes of JSON or a generated image. The client declares how many bytes it will accept for this call and stops reading the moment the body would exceed it, rather than downloading everything and checking afterwards. The first version of the shared transport did that with URLSession.AsyncBytes, appending one UInt8 per await. It was correct and it passed every test, because every consumer pulled small JSON and no test drove the loop itself. The first app to pull images through it showed the cost.
Measured again for this lesson on a 1 MiB body: the per-byte loop ran at about 24 MiB/s in a @concurrent function, and at about 0.1 MiB/s when the same loop ran on the main actor. The only difference was where the loop ran, and every element in it is an await. A URLSessionDataDelegate receives the body in chunks instead: one append per chunk, the running total checked each time, the task cancelled when it goes over. That version moved 8 MiB over loopback at about 1,500 MiB/s.
The kit's test found one more thing by failing. A server that understatesContent-Length and keeps sending cannot break the budget, because URLSession stops delivering at the declared length. The case that needs a running total is a reply with no length at all, framed only by the connection closing, and that is the one the test now serves.
Cancellation Reaches the Wire
Swift task cancellation does not cancel a URLSession task by itself. Wrap the continuation in withTaskCancellationHandler and cancel the task in the handler, so a view that disappears stops its download, and resume the continuation exactly once, from the completion callback.
Code
The same per-byte loop, on and off the main actor·swift
import Foundation
// Reading a 1 MiB body one byte at a time. Measured with Swift 6.3.3, -O, on macOS 26:
@MainActor
func readOnMainActor(_ url: URL) async throws -> Data { // 0.1 MiB/s
var out = Data()
let (bytes, _) = try await URLSession.shared.bytes(from: url)
for try await byte in bytes { out.append(byte) } // every element is an await
return out
}
@concurrent
func readOffTheMainActor(_ url: URL) async throws -> Data { // 23.8 MiB/s
var out = Data()
let (bytes, _) = try await URLSession.shared.bytes(from: url)
for try await byte in bytes { out.append(byte) }
return out
}
// The chunked delegate below moved 8 MiB over loopback HTTP at about 1,500 MiB/s.
One request: chunked reads, a budget enforced mid-stream, redirects refused, cancellation wired·swift
import Foundation
struct FetchReply: Sendable {
let status: Int
let body: Data
}
enum FetchFailure: Error, Equatable {
case overBudget(limit: Int)
case redirectRefused(status: Int)
case transport(URLError.Code)
}
/// One request, read in chunks, cut off the moment the body would exceed its budget.
final class BudgetedFetch: NSObject, URLSessionDataDelegate, @unchecked Sendable {
private let budget: Int
private let lock = NSLock()
private var body = Data()
private var failure: FetchFailure?
private var continuation: CheckedContinuation<FetchReply, Error>?
init(budget: Int) { self.budget = budget }
func run(_ request: URLRequest) async throws -> FetchReply {
let configuration = URLSessionConfiguration.ephemeral
configuration.httpCookieStorage = nil
configuration.urlCache = nil
configuration.timeoutIntervalForRequest = 15 // longest silence between bytes
configuration.timeoutIntervalForResource = 300 // the whole exchange
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }
let task = session.dataTask(with: request)
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
lock.withLock { self.continuation = continuation }
task.resume()
}
} onCancel: {
task.cancel()
}
}
// Passing nil stops at the 3xx instead of following it, and the fetch reports it as redirectRefused.
// A configuration alone cannot refuse a redirect.
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping @Sendable (URLRequest?) -> Void) {
lock.withLock { failure = .redirectRefused(status: response.statusCode) }
completionHandler(nil)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
let over = lock.withLock {
body.append(data) // one append per chunk, never per byte
guard body.count > budget else { return false }
failure = .overBudget(limit: budget)
return true
}
if over { dataTask.cancel() }
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
let (waiting, result) = lock.withLock { () -> (CheckedContinuation<FetchReply, Error>?, Result<FetchReply, Error>) in
defer { continuation = nil }
if let failure { return (continuation, .failure(failure)) }
if let error = error as? URLError { return (continuation, .failure(FetchFailure.transport(error.code))) }
if let error { return (continuation, .failure(error)) }
let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0
return (continuation, .success(FetchReply(status: status, body: body)))
}
waiting?.resume(with: result)
}
}
Write a tiny local server (Python's http.server is enough) with three routes: a 302 redirect, a small JSON reply, and a 4 MiB reply with no Content-Length that closes the connection when done. Put BudgetedFetch in SparkKit and prove all three outcomes with a budget of 1 MiB, then cancel a fetch of the large route from a Task and record the error. Finally, time the per-byte loop on the main actor and off it against the same route, and write both numbers into the type's header comment.
Hint
In Python, write the body in a loop after end_headers() without sending a length, and set close_connection = True. Run the Swift side as an @main executable, and remember that static func main() async in an @main type runs on the main actor.
Progress
Progress is local-only — sign in to sync across devices.