BS
BleepingSwift
Published on
8 min read

> Intercepting Network Requests in Swift with URLProtocol

Share:

URLProtocol is one of those Foundation classes that sounds like it should be a protocol in the Swift sense, but is actually an abstract class that you subclass. It sits inside the URL Loading System and gets a chance to handle every request a URLSession makes before the system reaches for its own HTTP, FTP, file, or data handler. If your subclass says yes, you take over and decide what data, response, and error the caller sees.

Once you internalize that, a lot of awkward networking problems get easier. You can return canned responses in tests without injecting a fake URLSession. You can log every request your app makes for a debug build. You can transparently rewrite hosts, add headers, or substitute a local file for a remote URL. The call sites do not change at all.

When You Actually Want This

The most common reason to reach for URLProtocol is testing. If your code accepts a URLSession as a dependency, mocking the responses can be done by configuring that session with a custom protocol class. The production code keeps making real URLSession.dataTask (or data(from:)) calls, and the test decides what comes back. You do not have to write a NetworkClient protocol just so you can mock it.

The second common use case is observability. A logging protocol can record every outgoing request, its response, and how long it took, all without the rest of the app knowing it exists. This is handy in TestFlight builds where you want to see what is happening over the wire without setting up a full proxy like Charles.

There are more exotic uses too. You can register a custom URL scheme so that myapp://something URLs flow through Foundation. You can serve a bundled JSON file in place of a remote endpoint when the device is offline. You can rewrite requests on the fly to point at a staging server. The pattern is the same in every case: subclass, register, intercept.

The Four Overrides You Need

A URLProtocol subclass has to answer four questions for the loading system:

Swift
import Foundation

final class MockURLProtocol: URLProtocol {
    override class func canInit(with request: URLRequest) -> Bool {
        // Return true for requests this class wants to handle.
        true
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        // Return a canonical form of the request. For most uses, just return it as-is.
        request
    }

    override func startLoading() {
        // Do the actual work and report results back through `client`.
    }

    override func stopLoading() {
        // Cancel any in-flight work. For synchronous canned responses, this can be empty.
    }
}

canInit(with:) is the bouncer. Return true only for requests you actually intend to take over, otherwise the request continues down the chain to other registered protocols and eventually to Foundation's built-in handlers. canonicalRequest(for:) is a chance to normalize equivalent requests so the cache and the loading system can match them up. For most app code there is nothing to canonicalize, and returning the request unchanged is the right answer.

The interesting work happens in startLoading(). That method does not return data directly. Instead, it calls back into the system through the client property, which is a URLProtocolClient. You tell the client about the response, the body data, and finally that you finished. Or, if something went wrong, you tell it about the error. The system delivers those callbacks back to whoever made the original request.

A Mock for Tests

Here is a complete subclass you can drop into a test target. It looks up requests by URL in a static dictionary and returns whatever you put there.

Swift
import Foundation

final class StubURLProtocol: URLProtocol {
    struct Stub {
        let statusCode: Int
        let headers: [String: String]
        let body: Data
    }

    static var stubs: [URL: Stub] = [:]

    override class func canInit(with request: URLRequest) -> Bool {
        guard let url = request.url else { return false }
        return stubs[url] != nil
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        request
    }

    override func startLoading() {
        guard let url = request.url, let stub = Self.stubs[url] else {
            client?.urlProtocol(self, didFailWithError: URLError(.fileDoesNotExist))
            return
        }

        let response = HTTPURLResponse(
            url: url,
            statusCode: stub.statusCode,
            httpVersion: "HTTP/1.1",
            headerFields: stub.headers
        )!

        client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
        client?.urlProtocol(self, didLoad: stub.body)
        client?.urlProtocolDidFinishLoading(self)
    }

    override func stopLoading() {}
}

The order of those three client calls matters. The system expects the response first, then any body data, then the finish notification. If you only call urlProtocol(_:didReceive:cacheStoragePolicy:) and forget to call urlProtocolDidFinishLoading(_:), the request will hang forever waiting for more data.

Plugging It Into a URLSession

You do not register a stub protocol globally with URLProtocol.registerClass, because that would affect every URLSession.shared request in the process and is generally a bad idea in tests. Instead, build a custom URLSessionConfiguration that lists your protocol class first:

Swift
import Foundation
import Testing

struct UserServiceTests {
    @Test
    func decodesProfileResponse() async throws {
        let url = URL(string: "https://api.example.com/me")!
        StubURLProtocol.stubs[url] = .init(
            statusCode: 200,
            headers: ["Content-Type": "application/json"],
            body: #"{"id":42,"name":"Ada"}"#.data(using: .utf8)!
        )

        let configuration = URLSessionConfiguration.ephemeral
        configuration.protocolClasses = [StubURLProtocol.self]
        let session = URLSession(configuration: configuration)

        let service = UserService(session: session)
        let profile = try await service.fetchProfile()

        #expect(profile.id == 42)
        #expect(profile.name == "Ada")

        StubURLProtocol.stubs.removeAll()
    }
}

The key detail is configuration.protocolClasses = [StubURLProtocol.self]. The system queries protocols in array order, so anything you put at the front gets first dibs on the request. The .ephemeral configuration is a good default for tests because it does not persist cookies or cache data between runs.

Note that URLSession.shared does not honor protocolClasses reliably across all schemes, which is the main reason you usually want a dedicated session for testing. If your production code has a hardcoded reference to URLSession.shared, refactor it to take a URLSession parameter so the tests can hand it a configured one.

A Logging Example

The same hook is great for observability. Here is a protocol that records every request the app makes and then forwards the actual loading to Foundation:

Swift
import Foundation
import os

final class LoggingURLProtocol: URLProtocol, @unchecked Sendable {
    private static let handledKey = "LoggingURLProtocol.handled"
    private var dataTask: URLSessionDataTask?

    override class func canInit(with request: URLRequest) -> Bool {
        // Avoid recursion: don't intercept requests we've already tagged.
        URLProtocol.property(forKey: handledKey, in: request) == nil
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        request
    }

    override func startLoading() {
        let mutable = (request as NSURLRequest).mutableCopy() as! NSMutableURLRequest
        URLProtocol.setProperty(true, forKey: Self.handledKey, in: mutable)
        let taggedRequest = mutable as URLRequest

        let session = URLSession(configuration: .ephemeral)
        let urlString = taggedRequest.url?.absoluteString ?? ""
        let method = taggedRequest.httpMethod ?? "GET"
        Logger.network.debug("→ \(method, privacy: .public) \(urlString, privacy: .public)")

        dataTask = session.dataTask(with: taggedRequest) { [weak self] data, response, error in
            guard let self else { return }
            if let error {
                Logger.network.error("✗ \(urlString, privacy: .public): \(error.localizedDescription, privacy: .public)")
                self.client?.urlProtocol(self, didFailWithError: error)
                return
            }
            guard let response, let data else {
                self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
                return
            }
            let status = (response as? HTTPURLResponse)?.statusCode ?? 0
            Logger.network.debug("← \(status) \(urlString, privacy: .public) \(data.count)B")
            self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            self.client?.urlProtocol(self, didLoad: data)
            self.client?.urlProtocolDidFinishLoading(self)
        }
        dataTask?.resume()
    }

    override func stopLoading() {
        dataTask?.cancel()
        dataTask = nil
    }
}

extension Logger {
    static let network = Logger(subsystem: "com.example.app", category: "network")
}

The trick to avoiding infinite recursion is the handledKey property. When startLoading() runs, it stamps the outgoing request with a custom property using URLProtocol.setProperty(_:forKey:in:). The internal URLSession it creates makes a new request, which triggers canInit(with:) again, but this time the property is set and the protocol declines to handle it. The request falls through to Foundation's normal HTTP loading.

The @unchecked Sendable annotation is there because URLProtocol is an NSObject subclass that predates Swift's strict concurrency model, and the loading system calls startLoading() and stopLoading() from its own internal queue. The dataTask property is only touched from those two methods, so the contract is safe in practice even though the compiler cannot prove it.

A Few Things to Keep in Mind

URLProtocol.registerClass(_:) registers globally for the rest of the process and affects sessions you do not own. Most of the time you want session-scoped registration through URLSessionConfiguration.protocolClasses instead. Reach for the global API only when you genuinely need to intercept code you do not control, like a third-party SDK that creates its own URLSession.shared calls.

URLSession only consults your protocol for the schemes it already knows how to dispatch. If you want to intercept a custom scheme like myapp:// from inside a WKWebView, that is a separate API (WKURLSchemeHandler). And background URLSession configurations do not honor custom protocol classes at all, since the loading happens in a system process.

For most apps the headline use is still tests. If you have ever written a MockURLSession wrapper to make a unit test happy, replacing it with a URLProtocol subclass is usually a smaller and more honest change. Your production code keeps using a real URLSession, your test code substitutes the configuration, and the seams between them get a little less load-bearing.

For more on the underlying API, the URLProtocol documentation is the authoritative reference, and the URLSessionConfiguration.protocolClasses page describes how registration order works.

Sample Project

Want to see this code in action? Check out the complete sample project on GitHub:

View on GitHub

The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.