iosdev

Thoughts on URLSession architecture

I hope I got it wrong here, I really do. Because it’s driving me bananas how convoluted this API is.

In my talk / post about LAYERS architecture I specifically pointed out how I deal with networking: I have downgraded it to pure, self-contained one-off utility.

In short – I have an Operation subclass which receives URLRequest + URLSessionConfiguration as input, creates an instance of URLSession inside the Op, execute the request and it all goes away once result is delivered. Each network request gets its own URLSession, 1 to 1.

This turned out to be the most controversial part; I received few direct and a bit more indirect feedback about this. Using URLSession in such a way is exactly what Apple recommends not to do (jump to ~ 32:40):

I know this and for the longest time I was going with one URLSession story in all my apps. Heck, it was trivial to update my current networking micro-library to allow such usage.

On the other hand, I do like Operations a lot. They give me so much flexibility, especially when I need to temporary pause the stream of requests to either fetch some session cookie or new OAuth access token. While that’s ongoing, all the incoming requests can easily be queued-up in the OperationQueue and later authenticated as needed.

Here’s the rub: the intricate way URLSession / URLSessionDataTask API is designed does not work well with Operations. That’s not obvious if you use the most popular form:

let task = localURLSession.dataTask(with: urlRequest, completionHandler: {
	[weak self] data, response, error in
	...
})

because that can be wrapped into Operation, very easily.

With huge caveat: it works as long as you don’t need to handle any sort of AuthenticationChallenge, say if your development API server is using self-signed SSL certificate. In which case completionHandler form is useless since you need to use URLSessionDelegate form:

let localURLSession = URLSession(configuration: urlSessionConfiguration, delegate: self, delegateQueue: nil)

let task = localURLSession.dataTask(with: urlRequest)

in order to handle the challenge. The very next slide in the same talk from WWDC17 mentions that particular fact:

Look at the last part: URLSession will still make authentication-related delegate callbacks even if you use completionHandler form for the DataTask. It does do that but it’s totally useless, because no matter what you do in the delegate callback, your completionHandler will always receive the same result, this SSL error:

Printing description of e._userInfo:
{
    NSErrorClientCertificateStateKey = 0;
    NSErrorFailingURLKey = "https://HOST/PATH";
    NSErrorFailingURLStringKey = "https://HOST/PATH";
    NSErrorPeerCertificateChainKey =     (
        "<cert(0x7fcbb48c1000) s: *.OTHERHOST.com i: RapidSSL SHA256 CA - G2>",
        "<cert(0x7fcbb48c1800) s: RapidSSL SHA256 CA - G2 i: GeoTrust Primary Certification Authority - G3>",
        "<cert(0x7fcbb48bd000) s: GeoTrust Primary Certification Authority - G3 i: GeoTrust Primary Certification Authority - G3>"
    );
    NSLocalizedDescription = "An SSL error has occurred and a secure connection to the server cannot be made.";
    NSLocalizedRecoverySuggestion = "Would you like to connect to the server anyway?";
    NSURLErrorFailingURLPeerTrustErrorKey = "<SecTrustRef: 0x608000109b40>";
    NSUnderlyingError = "Error Domain=kCFErrorDomainCFNetwork Code=-1200 \"(null)\" UserInfo={_kCFStreamPropertySSLClientCertificateState=0, kCFStreamPropertySSLPeerTrust=<SecTrustRef: 0x608000109b40>, _kCFNetworkCFStreamSSLErrorOriginalValue=-9802, _kCFStreamErrorDomainKey=3, _kCFStreamErrorCodeKey=-9802, kCFStreamPropertySSLPeerCertificates=(\n    \"<cert(0x7fcbb48c1000) s: *.OTHERHOST.com i: RapidSSL SHA256 CA - G2>\",\n    \"<cert(0x7fcbb48c1800) s: RapidSSL SHA256 CA - G2 i: GeoTrust Primary Certification Authority - G3>\",\n    \"<cert(0x7fcbb48bd000) s: GeoTrust Primary Certification Authority - G3 i: GeoTrust Primary Certification Authority - G3>\"\n)}";
    "_kCFStreamErrorCodeKey" = "-9802";
    "_kCFStreamErrorDomainKey" = 3;
}

If I do this in the callback:

func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {

	if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust {
		let trust = challenge.protectionSpace.serverTrust!
		let host = challenge.protectionSpace.host
		guard session.serverTrustPolicy.evaluate(trust, forHost: host) else {
			completionHandler(.rejectProtectionSpace, nil)
			return
		}

		let credential = URLCredential(trust: trust)
		completionHandler(.useCredential, credential)
		return
	}

	completionHandler(.performDefaultHandling, nil)
}

I expect it to accept my decision and trust the server, not give me the error. Otherwise what’s the point of making the callback then?

(This is the moment I hope someone will point out some glaring mistake I made here…)

UPDATE, Oct 23rd

Correct ATS incantation in Info.plist is the answer here, I have no idea why my previous attempts at it did not work. Here’s what I added in this test project so that completionHandler(.useCredential, credential) above works as expected:

<key>NSAppTransportSecurity</key>
<dict>
	<key>NSExceptionDomains</key>
	<dict>
		<key>self-signed.badssl.com</key>
		<dict>
			<key>NSExceptionAllowsInsecureHTTPLoads</key>
			<true/>
		</dict>
	</dict>
</dict>

Or in Xcode:

Going full-on URLSessionDelegate

Ok, let’s go entirely with delegate form and implement all of the callbacks. This is perfectly possible only if both URLSession instance and URLSessionDataTask instance are inside the Operation subclass which is acting as URLSessionDelegate.

Back in the Apple corner, they say I need to have one URLSession instance, which means it must be outside the Operation. However, once URLSession.delegate is assigned to something – that’s final. I can’t change it from one Operation to another. Understandably so since there’s no way to control where incoming data chunks end up if various Operations start stealing the delegate.

So the only solution seems to be: forget about concurrent network requests and execute them one by one. Does not sound right to me.

What I don’t grasp here is why there’s no such thing as URLSessionTask.delegate which will handle task specific stuff, like receiving data, response headers etc, for that specific request.

Also: if there should be one instance which handles session-level Auth challenges – like server trust – why then keep asking me to handle for each task in that same session. All the tasks are using the very same server. Honestly, I can imagine some MitM attack vector here so I sort of understand why they ask each time. But then – what’s the point on having the delegate on the URLSession level and not on the URLSessionTask?

The API design forces me to create some higher-level object to act as URLSessionDelegate which then forces me to build some convoluted structure of keeping track which URLSessionDataTask are active so I can route data received here:

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {}

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {}

into proper DataTask instances I keep…somewhere…

I don’t understand how to do concurrent network requests using URLSessionDelegate + replicate what OperationQueue/Operation gives me if I need to use the same session + delegate instance for all the tasks.

Ability to have totally independent network operations is way more important to me than adhering to these best practices.

UPDATE, Oct 23rd

I may have a solution here. Still need to test it in real project and see are there any drawbacks.