iOS Swift URLSession POST request getting duplicated for slow API calls

Viewed 759

I have a download task that work by first calling a REST API for which the server needs to generate a fairly large file which takes it several minutes to generate, as it is CPU and disk IO intensive. The client waits for the server to give a JSON response with the URL of the file it generated. The file download then starts after it gets the first result.

For the calls that generate a particularly large file, which causes the server to be very slow to respond, I am seeing duplicate requests that my code is not initiating.

Initially the someone who works on the server side told me about the duplicate requests. Then I set up a way to inspect network traffic. This was done by setting up a Mac connected to a wired network and enabling network sharing and using Proxyman to inspect the traffic from the iPhone to the API server. I see multiple instances of the same API request on the network layer but my code was never notified.

Code looks like this

@objc class OfflineMapDownloadManager : NSObject, URLSessionDelegate, URLSessionDownloadDelegate {
@objc func download(){     
    
    let config = URLSessionConfiguration.background(withIdentifier: "OfflineMapDownloadSession")
    config.timeoutIntervalForRequest = 500
    config.shouldUseExtendedBackgroundIdleMode = true
    config.sessionSendsLaunchEvents = true
  
    urlSession = URLSession(configuration: config, delegate: self, delegateQueue: nil)    
    getMapUrlsFromServer(bounds)
}


func getMapUrlsFromServer(){
    
    var urlString = "http://www.fake.com/DoMakeMap.php" 
    if let url = URL(string: urlString) {
        let request = NSMutableURLRequest(url: url)
        //...Real code sets up a JSON body in to params...
        request.httpBody = params.data(using: .utf8 )
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpMethod = "POST"
        request.timeoutInterval = 500
        urlSession?.configuration.timeoutIntervalForRequest = 500
        urlSession?.configuration.timeoutIntervalForResource = 500
        request.httpShouldUsePipelining = true
        let backgroundTask = urlSession?.downloadTask(with: request as URLRequest)
        backgroundTask?.countOfBytesClientExpectsToSend = Int64(params.lengthOfBytes(using: .utf8))
        backgroundTask?.countOfBytesClientExpectsToReceive = 1000
        backgroundTask?.taskDescription = "Map Url Download"
        backgroundTask?.resume()
    }
}

func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {    
    if (downloadTask.taskDescription == "CTM1 Url Download") {
        do {
            let data = try Data(contentsOf: location, options: .mappedIfSafe)
            let jsonResult = try JSONSerialization.jsonObject(with: data, options: .mutableLeaves)
            if let jsonResult = jsonResult as? Dictionary<String, AnyObject> {
                if let ctm1Url = jsonResult["CTM1Url"] as? String {
                    if let filesize = jsonResult["filesize"] as? Int {
                        currentDownload?.ctm1Url = URL(string: ctm1Url)
                        currentDownload?.ctm1FileSize = Int32(filesize)
                        if (Int32(filesize) == 0) {
                            postDownloadFailed()
                        } else {
                            startCtm1FileDownload(ctm1Url,filesize)
                        }
                    }
                }
            }
        } catch {
            postDownloadFailed()
        }
    }
}    

There is more to this download class as it will download the actual file once the first api call is done. Since the problem happens before that code would be executed, I did not include it in the sample code.

The log from Proxyman shows that the API call went out at (minutes:seconds) 46:06, 47:13, 48:21, 49:30, 50:44, 52:06, 53:45 enter image description here It looks like the request gets repeated with intervals that are just over 1 minute.

There is an API field where I can put any value and it will be echoed back to me by the server. I put a timestamp there generated with CACurrentMediaTime() and log in Proxyman shows that indeed its the same API call so there is no way my code is getting called multiple times. It seems as though the iOS networking layer is re-issuing the http request because the server is taking a very long time to respond. This ends up causing problems on the server and the API fails.

Any help would be greatly appreciated.

2 Answers

This sounds a lot like TCP retransmission. If the client sends a TCP segment, and the server does not acknowledge receipt within a short span of time, the client assumes the segment didn't make it to the destination, and it sends the segment again. This is a significantly lower-level mechanism than URLSession.

It's possible the HTTP server application this API is using (think Apache, IIS, LigHTTPd, nginx, etc.) is configured to acknowledge with the response data to save packeting and framing overhead. If so, and if the response data takes longer than the client's TCP retransmission timeout, you will get this behavior.

Do you have a packet capture of the connection? If not, try collecting one with tcpdump and reviewing it in Wireshark. If I'm right, you will see multiple requests, and they will all have the same sequence number.

As for how to fix it if that's the problem, I'm not sure. The server should acknowledge requests as soon as they are received.

I think the problem is in using URLSessionConfiguration.background(withIdentifier:) for this api call.

Use this method to initialize a configuration object suitable for transferring data files while the app runs in the background. A session configured with this object hands control of the transfers over to the system, which handles the transfers in a separate process. In iOS, this configuration makes it possible for transfers to continue even when the app itself is suspended or terminated.

So the problem is that the system is retrying your request unnecessarily because of this wrong API usage.

Here's what I recommend -

  1. Use default session configuration (NOT background).
  2. Do this api call that initiates this long job, do NOT have client wait on this job, from server side return a job_id back to client as soon as this job is initiated.
  3. Client can now poll server every X seconds using that job_id value to know about the status of the job, even can show progress on client side if needed.
  4. When job is completed, and client polls next time, it gets the download URL for this big file.
  5. Download the file (using default / background session configuration as you prefer).
Related