13  comments

Performing HTTP requests is all fun and games until there's no internet connection. Sure, you can display a sad error message or a "Chrome dinosaur" to the user, but wouldn't it be better to take the initiative and automatically retry the request when the user connects to a WiFi network or enables mobile data? This is precisely what we're going to implement in this tutorial.

Starter project

We're going to use the dio HTTP client and the connectivity package to accomplish our goal. We're not using the default http package because it's pretty lame. I mean, come on! No support for interceptors? These will be central to make this auto-retry work.

All of the packages and the basic UI is prepared for you in the starter project so that you won't waste time if you want to follow along. By the end of this tutorial, we'll have created an app with the following behavior:

Creating an interceptor

Interceptors run every time you do some action on a Dio object. You can then perform logic in three callback methods - onRequest, onResponse and onError.

They are useful for plenty of things such as logging or, in our case, scheduling a request retry when we detect there's no connection. You are probably familiar with the dreaded SocketException that gets thrown when the device isn't connected to a network. This means only one thing - we're going to utilize the onError callback.

retry_interceptor.dart

class RetryOnConnectionChangeInterceptor extends Interceptor {
  @override
  Future onError(DioError err) async {
    // TODO: Schedule a retry
  }
}

This is the basic outline of our interceptor. We're going to get to the retry logic in just a bit but first, it's important to note that the onError callback will run for all kinds of errors including status codes such as 401 or 503. I think it's a good idea to retry the request only when the error is the aforementioned SocketException.

How can we find out which exact type of of an error occurred?  We can use the handy fields of the DioError object!

retry_interceptor.dart

@override
Future onError(DioError err) async {
  if (_shouldRetry(err)) {
    // TODO: Schedule a retry
  }
  // Let the error "pass through" if it's not the error we're looking for
  return err;
}

bool _shouldRetry(DioError err) {
  return err.type == DioErrorType.DEFAULT &&
      err.error != null &&
      err.error is SocketException;
}
DioErrorType.DEFAULT means "some other error happened".  This includes platform exceptions which is what we're interested in.

Request retrier

Having the interceptor is only one part of the game. How can we actually schedule the failed requests to be retried as soon as the device is connected to a network? The answer is connectivity package, Stream and a Completer.

We could just plop this logic right into the RetryOnConnectionChangeInterceptor but I'm a proponent of keeping the code focused on one task. Let's create a DioConnectivityRequestRetrier.

dio_connectivity_request_retrier.dart

class DioConnectivityRequestRetrier {
  final Dio dio;
  final Connectivity connectivity;

  DioConnectivityRequestRetrier({
    @required this.dio,
    @required this.connectivity,
  });

  Future<Response> scheduleRequestRetry(RequestOptions requestOptions) async {
    // TODO: Implement
  }
}

scheduleRequestRetry will be passed the failed RequestOptions object which will be used to perform the same request for the second time. We're then going to return the successful Response back to the interceptor which scheduled a request retry.

To retry the request immediately when the network connection changes, the Connectivity class offers a handy Stream called onConnectivityChanged.

dio_connectivity_request_retrier.dart

Future<Response> scheduleRequestRetry(RequestOptions requestOptions) async {
  StreamSubscription streamSubscription;

  streamSubscription = connectivity.onConnectivityChanged.listen(
    (connectivityResult) async {
      // We're connected either to WiFi or mobile data
      if (connectivityResult != ConnectivityResult.none) {
        // Ensure that only one retry happens per connectivity change by cancelling the listener
        streamSubscription.cancel();
        // Copy & paste the failed request's data into the new request
        dio.request(
          requestOptions.path,
          cancelToken: requestOptions.cancelToken,
          data: requestOptions.data,
          onReceiveProgress: requestOptions.onReceiveProgress,
          onSendProgress: requestOptions.onSendProgress,
          queryParameters: requestOptions.queryParameters,
          options: requestOptions,
        );
      }
    },
  );
}
The connectivity package does not guarantee that the user is actually connected to the world-wide web. The user may just happen to use a WiFi network without any Internet access. That's not a problem though as in that case, the retry will again fail with a SocketException which you can catch as usual.

You may have noticed something fishy going on in the code above. There's no return statement! It's not as simple as returning the result of calling dio.request() directly. After all, the request happens inside a closure of the listen method and we want to return the Response from the scheduleRequestRetry method.

This is just the right occasion to use a Completer. We can return its Future from the whole scheduleRequestRetry, which we will then complete from the closure.

dio_connectivity_request_retrier.dart

Future<Response> scheduleRequestRetry(RequestOptions requestOptions) async {
  StreamSubscription streamSubscription;
  final responseCompleter = Completer<Response>();

  streamSubscription = connectivity.onConnectivityChanged.listen(
    (connectivityResult) async {
      if (connectivityResult != ConnectivityResult.none) {
        streamSubscription.cancel();
        // Complete the completer instead of returning
        responseCompleter.complete(
          dio.request(
            requestOptions.path,
            cancelToken: requestOptions.cancelToken,
            data: requestOptions.data,
            onReceiveProgress: requestOptions.onReceiveProgress,
            onSendProgress: requestOptions.onSendProgress,
            queryParameters: requestOptions.queryParameters,
            options: requestOptions,
          ),
        );
      }
    },
  );

  return responseCompleter.future;
}

Putting it all together

With this retrier class in place, we can now plug it into the interceptor.

retry_interceptor.dart

class RetryOnConnectionChangeInterceptor extends Interceptor {
  final DioConnectivityRequestRetrier requestRetrier;

  RetryOnConnectionChangeInterceptor({
    @required this.requestRetrier,
  });

  @override
  Future onError(DioError err) async {
    if (_shouldRetry(err)) {
      try {
        return requestRetrier.scheduleRequestRetry(err.request);
      } catch (e) {
        // Let any new error from the retrier pass through
        return e;
      }
    }
    // Let the error pass through if it's not the error we're looking for
    return err;
  }

  bool _shouldRetry(DioError err) {
    return err.type == DioErrorType.DEFAULT &&
        err.error != null &&
        err.error is SocketException;
  }
}

The main.dart file from the starter project contains a working code to perform GET requests with Dio. All we need to do, is to plug the interceptor into it.

main.dart

class _HomePageState extends State<HomePage> {
  Dio dio;
  String firstPostTitle;
  bool isLoading;

  @override
  void initState() {
    super.initState();
    dio = Dio();
    firstPostTitle = 'Press the button ?';
    isLoading = false;

    dio.interceptors.add(
      RetryOnConnectionChangeInterceptor(
        requestRetrier: DioConnectivityRequestRetrier(
          dio: dio,
          connectivity: Connectivity(),
        ),
      ),
    );
  }
  ...
}

And just like that, you now know how to retry requests automatically when device connection state changes. It's going to create a much better user experience than just passively saying "no connection, retry by pressing a button".

About the author 

Matt Rešetár

Matt is an app developer with a knack for teaching others. Working as a freelancer and most importantly developer educator, he is set on helping other people succeed in their Flutter app development career.

You may also like

  • Thanks, Matt!

    I had to place
    streamSubscription.cancel();
    directly after
    responseCompleter.complete();
    in order for it to work on my android device!

    Cheers 😀

  • How might you go about cancelling the retrier after it has activated, in case you don’t want it to continue watching for the change to happen (i.e. the user gets tired of waiting, or decides they will come back later).?

  • options: requestOptions,
    is giving error saying
    The argument type ‘RequestOptions’ can’t be assigned to the parameter type ‘Options’

  • Hi , This is not working in dio 4.0.0 .Can you give any suggestions how to make work this concept with dio 4.0.0.

    • I was able to make it work with dio 4.0.0 using the handler. You need to pass it to your scheduleRequestRetry function and resolve or pass the response of retry request. Something like this:

      void scheduleRequestRetry(DioError error, ErrorInterceptorHandler handler) {
      late StreamSubscription streamSubscription;

      streamSubscription = connectivity.onConnectivityChanged.listen(
      (connectivityResult) async {
      if (connectivityResult != ConnectivityResult.none) {
      streamSubscription.cancel();
      try {
      var response = await dio.fetch(error.requestOptions);
      handler.resolve(response);
      } on DioError catch (retryError) {
      handler.next(retryError);
      }
      }
      },
      );
      }

      and the retry_interceptore becomes the following:

      if (_shouldRetry(err)) {
      return requestRetrier.scheduleRequestRetry(err, handler);
      }
      return super.onError(err, handler);

      I am not sure this is the best way to do it but it’s working.

  • {"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
    >