Eric Eric - 7 months ago 26
Swift Question

Recursive/looping NSURLSession async completion handlers

The API I use requires multiple requests to get search results. It's designed this way because searches can take a long time (> 5min). The initial response comes back immediately with metadata about the search, and that metadata is used in follow up requests until the search is complete. I do not control the API.


  • 1st request is a POST to https://api.com/sessions/search/

  • The response to this request contains a cookie and metadata about the search. The important fields in this response are the
    search_cookie
    (a String) and
    search_completed_pct
    (an Int)

  • 2nd request is a POST to https://api.com/sessions/results/ with the
    search_cookie
    appended to the URL. eg https://api.com/sessions/results/c601eeb7872b7+0

  • The response to the 2nd request will contain either:


    • The search results if the query has completed (aka
      search_completed_pct
      == 100)

    • Metadata about the progress of search,
      search_completed_pct
      is the progress of the search and will be between 0 and 100.


  • If the search is not complete, I want to make a request every 5 seconds until it's complete (aka
    search_completed_pct
    == 100)



I've found numerous posts here that are similar, many use Dispatch Groups and for loops, but that approach did not work for me. I've tried a while loop and had issues with variable scoping. Dispatch groups also didn't work for me. This smelled like the wrong way to go, but I'm not sure.

I'm looking for the proper design to make these recursive calls. Should I use delegates or are closures + loop the way to go? I've hit a wall and need some help.

The code below is the general idea of what I've tried (edited for clarity. No dispatch_groups(), error handling, json parsing, etc.)

Viewcontroller.swift

apiObj.sessionSearch(domain) { result in
Log.info!.message("result: \(result)")
})


ApiObj.swift

func sessionSearch(domain: String, sessionCompletion: (result: SearchResult) -> ()) {

// Make request to /search/ url
let task = session.dataTaskWithRequest(request) { data, response, error in
let searchCookie = parseCookieFromResponse(data!)

********* pseudo code **************
var progress: Int = 0
var results = SearchResults()

while (progress != 100) {

// Make requests to /results/ until search is complete
self.getResults(searchCookie) { searchResults in
progress = searchResults.search_pct_complete
if (searchResults == 100) {
completion(searchResults)
} else {
sleep(5 seconds)
} //if
} //self.getResults()
} //while
********* pseudo code ************
} //session.dataTaskWithRequest(
task.resume()
}


func getResults(cookie: String, completion: (searchResults: NSDictionary) -> ())

let request = buildRequest((domain), url: NSURL(string: ResultsUrl)!)
let session = NSURLSession.sharedSession()
let task = session.dataTaskWithRequest(request) { data, response, error in
let theResults = getJSONFromData(data!)
completion(theResults)
}
task.resume()
}

Answer

Well first off, it seems weird that there is no API with a GET request which simply returns the result - even if this may take minutes. But, as you mentioned, you cannot change the API.

So, according to your description, we need to issue a request which effectively "polls" the server. We do this until we retrieved a Search object which is completed.

So, a viable approach would purposely define the following functions and classes:

A protocol for the "Search" object returned from the server:

public protocol SearchType {
    var searchID: String { get }
    var isCompleted: Bool { get }
    var progress: Double { get }
    var result: AnyObject? { get }
}

A concrete struct or class is used on the client side.

An asynchronous function which issues a request to the server in order to create the search object (your #1 POST request):

func createSearch(completion: (SearchType?, ErrorType?) -> () )

Then another asynchronous function which fetches a "Search" object and potentially the result if it is complete:

func fetchSearch(searchID: String, completion: (SearchType?, ErrorType?) -> () )

Now, an asynchronous function which fetches the result for a certain "searchID" (your "search_cookie") - and internally implements the polling:

func fetchResult(searchID: String, completion: (AnyObject?, ErrorType?) -> () )

The implementation of fetchResult may now look as follows:

func fetchResult(searchID: String, 
    completion: (AnyObject?, ErrorType?) -> () ) {
    func poll() {
        fetchSearch(searchID) { (search, error) in
            if let search = search {
                if search.isCompleted {
                    completion(search.result!, nil)
                } else {
                    delay(1.0, f: poll)
                }
            } else {
                completion(nil, error)
            }
        }
    }
    poll()
}

This approach uses a local function poll for implementing the polling feature. poll calls fetchSearch and when it finishes it checks whether the search is complete. If not it delays for certain amount of duration and then calls poll again. This looks like a recursive call, but actually it isn't since poll already finished when it is called again. A local function seems appropriate for this kind of approach.

The function delay simply waits for the specified amount of seconds and then calls the provided closure. delay can be easily implemented in terms of dispatch_after or a with a cancelable dispatch timer (we need later implement cancellation).

I'm not showing how to implement createSearch and fetchSearch. These may be easily implemented using a third party network library or can be easily implemented based on NSURLSession.

Conclusion:

What might become a bit cumbersome, is to implement error handling and cancellation, and also dealing with all the completion handlers. In order to solve this problem in a concise and elegant manner I would suggest to utilise a helper library which implements "Promises" or "Futures" - or try to solve it with Rx.

For example a viable implementation utilising "Scala-like" futures:

func fetchResult(searchID: String) -> Future<AnyObject> {
    let promise = Promise<AnyObject>()
    func poll() {
        fetchSearch(searchID).map { search in
            if search.isCompleted {
                promise.fulfill(search.result!)
            } else {
                delay(1.0, f: poll)
            }
        }
    }
    poll()
    return promise.future!
}

You would start to obtain a result as shown below:

createSearch().flatMap { search in
    fetchResult(search.searchID).map { result in
        print(result)
    }
}.onFailure { error in
    print("Error: \(error)")
}

This above contains complete error handling. It does not yet contain cancellation. Your really need to implement a way to cancel the request, otherwise the polling may not be stopped.

A solution implementing cancellation utilising a "CancellationToken" may look as follows:

func fetchResult(searchID: String, 
    cancellationToken ct: CancellationToken) -> Future<AnyObject> {
    let promise = Promise<AnyObject>()
    func poll() {
        fetchSearch(searchID, cancellationToken: ct).map { search in
            if search.isCompleted {
                promise.fulfill(search.result!)
            } else {
                delay(1.0, cancellationToken: ct) { ct in
                    if ct.isCancelled {
                        promise.reject(CancellationError.Cancelled)
                    } else {
                        poll()
                    }
                }
            }
        }
    }
    poll()
    return promise.future!
}

And it may be called:

let cr = CancellationRequest()
let ct = cr.token
createSearch(cancellationToken: ct).flatMap { search in
    fetchResult(search.searchID, cancellationToken: ct).map { result in
        // if we reach here, we got a result
        print(result)
    }
}.onFailure { error in
    print("Error: \(error)")
}

Later you can cancel the request as shown below:

cr.cancel()
Comments