I have a custom server written in Swift, using Kitura (http://www.kitura.io), running on an AWS EC2 server (under Ubuntu 16.04). I am securing it using a CA signed SSL certificate (https://letsencrypt.org), so I can use https to connect from the client to the server. The client runs natively under iOS (9.3). I use URLSession's on iOS to connect to the server.
I'm having client timeout problems when I make multiple largish downloads to the iOS client back to back. The timeouts look like:
Error Domain=NSURLErrorDomain Code=-1001 "The request timed out." UserInfo={NSErrorFailingURLStringKey=https://, _kCFStreamErrorCodeKey=-2102, NSErrorFailingURLKey=https://, NSLocalizedDescription=The request timed out., _kCFStreamErrorDomainKey=4, NSUnderlyingError=0x7f9f23d0 {Error Domain=kCFErrorDomainCFNetwork Code=-1001 "(null)" UserInfo={_kCFStreamErrorDomainKey=4, _kCFStreamErrorCodeKey=-2102}}}
On the server, the timeouts always occur at the same place in the code-- and they cause the specific server request thread to block and never recover. The timeouts occur just as the server thread calls the Kitura RouterResponse
end
method. i.e., the server thread blocks when it calls this end
method. In light of this, it is not surprising that the client app times out. This code is open-source, so I'll link to where the server blocks: https://github.com/crspybits/SyncServerII/blob/master/Server/Sources/Server/ServerSetup.swift#L146
The client-side test that fails is: https://github.com/crspybits/SyncServerII/blob/master/iOS/Example/Tests/Performance.swift#L53
I am not downloading from something like Amazon S3. The data is is obtained on the server from another web source, and then downloaded to my client via https from the server running on EC2.
As an example, it's taking 3-4 seconds to download 1.2 MB of data, and when I try 10 of these 1.2 MB downloads back to back, three of them timeout. The downloads occur using a HTTPS GET request.
One thing of interest is that the test that does these downloads first does uploads of the same data sizes. I.e., it does 10 uploads at 1.2 MB each. I've seen no timeout failures with those uploads.
Most of my requests do work, so this does not appear to be simply a problem with, say, an improperly installed SSL certificate (I've checked that with https://www.sslshopper.com). Nor does it seem to be a problem with improper https setup on the iOS side, where I've got NSAppTransportSecurity
setup in my app .plist using Amazon's recommendation (https://aws.amazon.com/blogs/mobile/preparing-your-apps-for-ios-9/).
Thoughts?
Update1: I just tried this with my server running on a local Ubuntu 16.04 system, and using a self-signed SSL certificate-- other factors remaining the same. I get the same issue coming up. So, it seems clear this does not relate to AWS specifically.
Update2: With the server running on the local Ubuntu 16.04 system, and without using SSL (just a one line change in the server code and the use of http as opposed to https in the client), the issue is not present. The downloads occur successfully. So, it seems clear that this issue does relate to SSL.
Update3:
With the server running on the local Ubuntu 16.04 system, and using the self-signed SSL certificate again, I used a simple curl
client. In order to simulate the test I've been using as closely as possible, I interrupted the existing iOS client test just as it was beginning to start its downloads, and restarted using my curl
client-- which used the download endpoint on the server to download the same 1.2MB file 20 times. The error did not replicate. My conclusion is that the problem stems from an interaction between the iOS client and SSL.
Update4:
I now have a simpler version of the iOS client reproducing the issue. I'll copy it in below, but in summary, it uses URLSession
's and I see the same timeout issue (the server is running on my local Ubuntu system using the self-signed SSL certificate). When I disable the SSL usage (http and no SSL certificate used on the server), I do not get the issue.
Here's the simpler client:
class ViewController: UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
download(10)
}
func download(_ count:Int) {
if count > 0 {
let masterVersion = 16
let fileUUID = "31BFA360-A09A-4FAA-8B5D-1B2F4BFA5F0A"
let url = URL(string: "http://127.0.0.1:8181/DownloadFile/?fileUUID=\(fileUUID)&fileVersion=0&masterVersion=\(masterVersion)")!
Download.session.downloadFrom(url) {
self.download(count - 1)
}
}
}
}
// In a file named "Download.swift":
import Foundation
class Download : NSObject {
static let session = Download()
var authHeaders:[String:String]!
override init() {
super.init()
authHeaders = [
<snip: HTTP headers specific to my server>
]
}
func downloadFrom(_ serverURL: URL, completion:@escaping ()->()) {
let sessionConfiguration = URLSessionConfiguration.default
sessionConfiguration.httpAdditionalHeaders = authHeaders
let session = URLSession(configuration: sessionConfiguration, delegate: self, delegateQueue: nil)
var request = URLRequest(url: serverURL)
request.httpMethod = "GET"
print("downloadFrom: serverURL: \(serverURL)")
var downloadTask:URLSessionDownloadTask!
downloadTask = session.downloadTask(with: request) { (url, urlResponse, error) in
print("downloadFrom completed: url: \(String(describing: url)); error: \(String(describing: error)); status: \(String(describing: (urlResponse as? HTTPURLResponse)?.statusCode))")
completion()
}
downloadTask.resume()
}
}
extension Download : URLSessionDelegate, URLSessionTaskDelegate /*, URLSessionDownloadDelegate */ {
public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Swift.Void) {
completionHandler(URLSession.AuthChallengeDisposition.useCredential, URLCredential(trust: challenge.protectionSpace.serverTrust!))
}
}
Update5:
Whew! I'm making progress in the right direction now! I now have a simpler iOS client using SSL/https and not causing this issue. The change was suggested by @Ankit Thakur: I'm now using URLSessionConfiguration.background
instead of URLSessionConfiguration.default
, and that seems to have been what makes this work. I am not sure why though. Does this represent a bug in URLSessionConfiguration.default
? e.g., my app is not explicitly going into the background during my tests. Also: I'm not sure how or if I'm going to be able to use this pattern of code in my client app-- it seems like this usage of URLSession
's does not let you change the httpAdditionalHeaders
after you create the URLSession. And it appears the intent of the URLSessionConfiguration.background
is that the URLSession
should live for the duration of the app's lifetime. This is a problem for me because my HTTP headers can change during a single launch of the app.
Here's is my new Download.swift code. The other code in my simpler example remains the same:
import Foundation
class Download : NSObject {
static let session = Download()
var sessionConfiguration:URLSessionConfiguration!
var session:URLSession!
var authHeaders:[String:String]!
var downloadCompletion:(()->())!
var downloadTask:URLSessionDownloadTask!
var numberDownloads = 0
override init() {
super.init()
// https://developer.apple.com/reference/foundation/urlsessionconfiguration/1407496-background
sessionConfiguration = URLSessionConfiguration.background(withIdentifier: "MyIdentifier")
authHeaders = [
<snip: my headers>
]
sessionConfiguration.httpAdditionalHeaders = authHeaders
session = URLSession(configuration: sessionConfiguration, delegate: self, delegateQueue: OperationQueue.main)
}
func downloadFrom(_ serverURL: URL, completion:@escaping ()->()) {
downloadCompletion = completion
var request = URLRequest(url: serverURL)
request.httpMethod = "GET"
print("downloadFrom: serverURL: \(serverURL)")
downloadTask = session.downloadTask(with: request)
downloadTask.resume()
}
}
extension Download : URLSessionDelegate, URLSessionTaskDelegate, URLSessionDownloadDelegate {
public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Swift.Void) {
completionHandler(URLSession.AuthChallengeDisposition.useCredential, URLCredential(trust: challenge.protectionSpace.serverTrust!))
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
print("download completed: location: \(location); status: \(String(describing: (downloadTask.response as? HTTPURLResponse)?.statusCode))")
let completion = downloadCompletion
downloadCompletion = nil
numberDownloads += 1
print("numberDownloads: \(numberDownloads)")
completion?()
}
// This gets called even when there was no error
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
print("didCompleteWithError: \(String(describing: error)); status: \(String(describing: (task.response as? HTTPURLResponse)?.statusCode))")
print("numberDownloads: \(numberDownloads)")
}
}
Update6:
I see now how to deal with the HTTP header situation. I can just use the allHTTPHeaderFields
property of the URLRequest. Situation should be basically solved!
Update7: I may have figured out why the background technique works:
Any upload or download tasks created by a background session are automatically retried if the original request fails due to a timeout.
code looks good for client side. Would you try SessionConfiguration
to background
instead of default
. let sessionConfiguration = URLSessionConfiguration.default
.
There are many scenarios, where I have found .background
is working much better than .default
.
e.g. timeout, GCD support, background download.
I always prefer to use .background
session configuration.