Pattern for retrying URLSession dataTask?
Asked Answered
J

2

15

I'm fairly new to iOS/Swift development and I'm working on an app that makes several requests to a REST API. Here's a sample of one of those calls which retrieves "messages":

func getMessages() {

    let endpoint = "/api/outgoingMessages"

    let parameters: [String: Any] = [
        "limit" : 100,
        "sortOrder" : "ASC"
    ]

    guard let url = createURLWithComponents(endpoint: endpoint, parameters: parameters) else {
        print("Failed to create URL!")
        return
    }

    do {
        var request = try URLRequest(url: url, method: .get)

        let task = URLSession.shared.dataTask(with: request as URLRequest) { (data, response, error) in

            if let error = error {
                print("Request failed with error: \(error)")
                // TODO: retry failed request
            } else if let data = data, let response = response as? HTTPURLResponse {                
                if response.statusCode == 200 {
                    // process data here
                } else {
                    // TODO: retry failed request
                }
            }
        }

        task.resume()

    } catch {
        print("Failed to construct URL: \(error)")
    }
}

Of course, it's possible for this request to fail for a number of different reasons (server is unreachable, request timed out, server returns something other than 200, etc). If my request fails, I'd like to have the ability to retry it, perhaps even with a delay before the next attempt. I didn't see any guidance on this scenario in Apple's documentation but I found a couple of related discussions on SO. Unfortunately, both of those were a few years old and in Objective-C which I've never worked with. Are there any common patterns or implementations for doing something like this in Swift?

Jabon answered 19/10, 2017 at 15:5 Comment(9)
it depends, are these requests tied to the UI? (i.e. do you need the data from your server to display things). Or is it just backend logic where you need to store data from the app to the serverHydrastis
@Hydrastis the short answer is both; some of the requests are just for populating a database while others will need to update the UIJabon
for the UI one there's many ways to handling it but more often than not I feel like the general standard is to let the user know they have no internet and allow for a way for the user to refresh when they do (or you can use reachability and constantly listen to wait for a network connection to be available and then make your request, theres many other ways too im sure). As for the backend one do you need to have your data in the database up to date as much as possible? or is it not a big deal if it comes in a little bit later. You can see how this plays a huge role in retry logicHydrastis
@Hydrastis thanks for the suggestions. I'm actually using reachability now to get an idea if I have connectivity or not. The example request shown in my question is triggered by a push notification that is sent from the server. The user might not be looking at the part of the app that displays that data but the expectation is that the data will be fetched and inserted in the database almost immediately.Jabon
I don't work too much with remote notifications anymore but can't you just push the data you need through the remote notification as oppose to calling the server? Also, the part of inserting into the database via request to a server if you want it immediately then I'd probably go with a timer in the background that is retrying it over and over again like you said in the OP. But obvious problems are when users leave the app or terminate it, in that case youre out of luck and would have to begin retrying again when they open up the app again. (maybe something in like applicationDidBecomeActiveHydrastis
I feel like for the UI one it would then be a combination of background retry logic and also showing the user theres no internet and allowing them to manually retry at the same time (Although it's hard to say without seeing the details of the project)Hydrastis
@Hydrastis remote notifications do not have the bandwidth to deliver data; they can, however, deliver a notification to the app that it should update itself in the background using background app refresh.Clunk
@Clunk yah you're right was just seeing if it was a possiblityHydrastis
Related to Brandon's notification suggestion, see hereTamarau
C
11

This question is airing on the side of opinion-based, and is rather broad, but I bet most are similar, so here goes.

For data updates that trigger UI changes:

(e.g. a table populated with data, or images loading) the general rule of thumb is to notify the user in a non-obstructing way, like so:

And then have a pull-to-refresh control or a refresh button.

For background data updates that don't impact the user's actions or behavior:

You could easily add a retry counter into your request result depending on the code - but I'd be careful with this one and build out some more intelligent logic. For example, given the following status codes, you might want to handle things differently:

  • 5xx: Something is wrong with your server. You may want to delay the retry for 30s or a minute, but if it happens 3 or 4 times, you're going to want to stop hammering your back end.

  • 401: The authenticated user may no longer be authorized to call your API. You're not going to want to retry this at all; instead, you'd probably want to log the user out so the next time they use your app they're prompted to re-authenticate.

  • Network time-out/lost connection: Retrying is irrelevant until connection is re-established. You could write some logic around your reachability handler to queue background requests for actioning the next time network connectivity is available.

And finally, as we touched on in the comments, you might want to look at notification-driven background app refreshing. This is where instead of polling your server for changes, you can send a notification to tell the app to update itself even when it's not running in the foreground. If you're clever enough, you can have your server repeat notifications to your app until the app has confirmed receipt - this solves for connectivity failures and a myriad of other server response error codes in a consistent way.

Clunk answered 19/10, 2017 at 15:52 Comment(15)
Great points you make here. But I would be careful with 401 not retrying at all. This could lead to potentially losing important data.Hydrastis
Also again, for the 500 once again you need to be careful. Just because it's 5xx and you retry x amount of times doesn't mean you should stop retrying. Getting a 5xx doesn't always mean that something is wrong with your server. It could have perfect logic but sometimes you will still have to return a 5xx (consider when you try to write to your database and your database is down). There's also many other reasons why you would still want to always try to retry on a 5xxHydrastis
If you're getting a 401 back from the server, chances are you'll never get data back no matter how many times you retry, until the app has received a new token on behalf of the user.Clunk
And for 5xx, that's why I suggested retrying after a longer interval. If your calls are being rate-limited for example, an attempt after 30-60s might resolve it.Clunk
ah, I meant to say for when he's posting something to his server to update his database. I don't mean when you're making a request to update the UI maybe I should have stated thatHydrastis
yes for 5xx retrying over and over again with longer increasing intervals is good. I agree, but to say that you should completely stop retrying after x amount of times is simply not a good idea if the data is vital.Hydrastis
Really for 5xx you need some context. Bad query from client won’t fix it. Rate limiting or quota or threat protection? You’d need some error details to do it right.Clunk
by confirmed receipt you mean what? You mean the app has to make a confirmed call, or the server itself knows when it successfully reaches its destination? I'm not a server-side dev, can you explain how that happens?Tamarau
You’d set up your app to respond to a notification by sending your request and including a notification ID. When the server receives an API call with the same notification ID, you can mark that notification as delivered. This allows you to write that retry logic into your server instead of the client.Clunk
Placing all the retry logic onto the server has pros to it but it also has a lot of draw backs. There's many things we can't know 1. Will not tell whether the message was sent successfully or not 2.Will not tell if the user has opted out of Push Notifications - #25831097 I'm not sure if this has changed since then, I hope it did but otherwise one can easily see how this could be discouraging to use for the idea of retry logic. (There are also many other drawbacks to consider as well)Hydrastis
so roughly speaking something like sendToServerRecieved(NotificationID) I don't see why you also need to add the request, the ID should suffice to mark it as received right? PS can you also add the @honey when responding?Tamarau
@honey something like that, or you can include the notification ID in the request header that you send. Depends how you want to set up your back end.Clunk
You should not automatically log the user out upon a 401 error. This leads to awful experiences like having to retype your username and password on a tiny iPhone screen over and over because an authentication server has coughed up a hairball. Instead, you should try again silently after a period of time, and show an informational notice that offers the user the opportunity to log in again if he/she has changed his/her password recently. Always provide info, but never force the user to act.Fogel
Otherwise, I generally agree with this advice, but I would add info about reachability for situations where the error is a network failure rather than the server sending an error code.Fogel
True use of the 401 suggests that the user is not authorized to access the resource. If the token has been revoked by the server, the user should no longer be allowed to access any resources. Don’t compromise security for user experience; instead, solve UX problems for security decisions. E.g. your concern can be mitigated by integrating with device auth like TouchID/FaceID for iOS, or password tools like 1Password.Clunk
L
9

I'd categorize three methods for handling retry:

  1. Reachability Retry
  • Reachability is a fancy way of saying "let me know when network connection has changed". Apple has some snippets for this, but they aren't fun to look at — my recommendation is to use something like Ashley Mill's Reachability replacement.
  • In addition to Reachability, Apple provides a waitsForConnectivity (iOS 11+) property that you can set on the URLSession configuration. By setting it, you are alerted via the URLSessionDataDelegate when a task is waiting for a network connection. You could use that opportunity to enable an offline mode or display something to the user.
  1. Manual Retry
  • Let the user decide when to retry the request. I'd say this is most commonly implemented using a "pull to refresh" gesture/UI.
  1. Timed/Auto Retry
  • Wait for a few second and try again.
  • Apple's Combine framework provides a convenient way to retry failed network requests. See Processing URL Session Data Task Results with Combine
  • From Apple Docs: Life Cycle of a URL Session (deprecated)... your app should not retry [a request] immediately, however. Instead, it should use reachability APIs to determine whether the server is reachable, and should make a new request only when it receives a notification that reachability has changed.
Loci answered 3/11, 2017 at 21:10 Comment(4)
Good comments. To that, I would suggest adding info about when you should use each approach. If an action is a "get data" action initiated by a user and the user is waiting for a response, you should show an error, and allow the user to choose when to retry, rather than have the screen refresh unexpectedly. If an action is a "post data" action initiated by a user, the app should tell the user that it can't post right now, but it will automatically be posted when the user comes online. For background requests, always use reachability.Fogel
I would also add that reachability can lie to you, so never gate any initial request on reachability even if previous requests have failed. Try first, and use reachability if the attempt fails. In high-latency environments, for idempotent requests, it may also be beneficial to try two or three requests in a delayed-parallel fashion with exponential backoff, and only use reachability after [n] failures, because packet loss tends to be bursty.Fogel
In the docs no any more info about life cycle of retry.Kaciekacy
@Kaciekacy I updated my answer to show that the link is deprecated and added a new link referring to the retry available in the Combine frameworkLoci

© 2022 - 2024 — McMap. All rights reserved.