Gonçalo Palma
September 8, 2019

Dio Interceptors in Flutter

Sometimes we don’t need complex apps, sometimes we just need an app that displays a list of items from one endpoint, and we can manage to do it with a simple method:

Future<Response> fetchItems() {
  return Dio().("https://some-website.com/listing");
}

And we don’t have any errors, there’s no need to log our responses. What about cache? Nobody cares about it!

But truth be told, almost no apps are as simple as this one. Some apps do require more in order for us to get the response from our request, such as:

Interceptors will help us handle this by giving us specific callbacks for errors, requests and response.

Before diving into how we can use interceptors, let’s take a step back and look at how we can configure Dio.

Dio Configuration

Dio can be configured with a BaseOption object that let us initialise a new Dio instance with a set of rules: connectTimeout, receiveTimeout and baseUrl that will be used for every API call we make.

Dio createDio() {
  return Dio(
    BaseOptions(
      connectTimeout: 5000,
      receiveTimeout: 5000,
      baseUrl: "https://some-website.com"
    )
  );
}

However, one thing that we cannot add in the base configurations (at the time the article was written) is interceptors. For that, we need to create the new Dio instance and add the interceptors that we want in the interceptors list.

Dio addInterceptors(Dio dio) {
  return dio..interceptors.add(InterceptorsWrapper(
      onRequest: (RequestOptions options) => requestInterceptor(options),
      onResponse: (Response response) => responseInterceptor(response),
      onError: (DioError dioError) => errorInterceptor(dioError)));
}

And with this, we have setup a Dio instance that can be used for any API call that we make.

Adding dynamic headers

As stated in the introduction, let us suppose that the app that we are making needs a header that contains a value stored in the shared preferences. Additionally, that value must have the timestamp and can change at any time.

/As a side note/: Since the data can be changed, we cannot use the BaseOptions’s extra field, which would conveniently let us access data static data that we passed to it on its creation. So, we will need to access the shared preferences each time we are making a request.

The InterceptorsWrapper gives us the RequestOptions object which has the following properties:

With this information, we can start implementing our requestInterceptor method.

This method returns a dynamic type that can be:

This will let us have the flexibility to validate each request before it’s being made, add data, and throw any error if necessary. For our case, we just need to add some data and proceed with the request.

dynamic requestInterceptor(RequestOptions options) async {
  SharedPreferences prefs = await SharedPreferences.getInstance();
  var token = prefs.get("token");

  options.headers.addAll({"Token": "$token${DateTime.now()}"});

	return options;
}

Knock, knock the Project Manager comes to tell you that now, for some reason, you cannot send the headers for a specific set of endpoints.

We could quickly solve this by using a switch statement with the path parameter. But we don’t want that. We want to be able to see in the endpoint declaration if that endpoints needs, or does not need the token. So, instead of searching for the paths in the interceptor, we are going to add an auxiliary header to each endpoint request.

Future<Response> getListOfTodos() {
  return dio.get("/todos/1", options: Options(headers: {"requiresToken" : true}));
}

Then, we will be able to verify if the request has that header, remove it, and add the token.

dynamic requestInterceptor(RequestOptions options) async {
  if (options.headers.containsKey("requiresToken")) {
    //remove the auxiliary header
    options.headers.remove("requiresToken");

    SharedPreferences prefs = await SharedPreferences.getInstance();
    var header = prefs.get("Header");

    options.headers.addAll({"Header": "$header${DateTime.now()}"});
    
    return options;
  }
}

Verifying the Response

As it was with the Request, we also have a specific key stored in our shared preferences with which we must verify our responses. If we cannot find the specified header or if the key is different than the one stored, we throw an error message saying that the user is no longer active.

In the same way that we created a method for the request, we can do the same for the response. The key difference is that now we are dealing with a Response object which in part has the same data as the Request such as data, headers but it also has the statusCode and the original Request data.

As with the response, we have a dynamic type as a return for this function which can be:

Which means that, if the boolean value for the header isUserActive is false, we can return a DioError object as follows:

dynamic responseInterceptor(Response options) async {
  if (options.headers.value("verifyToken") != null) {
    //if the header is present, then compare it with the Shared Prefs key
    SharedPreferences prefs = await SharedPreferences.getInstance();
    var verifyToken = prefs.get("VerifyToken");
    
    // if the value is the same as the header, continue with the request
    if (options.headers.value("verifyToken") == verifyToken) {
      return options;
    }
  }

  return DioError(request: options.request, message: "User is no longer active");
}

Verifying Errors from the Server

Let us assume that we have some mechanism in our server that can delete a user account completely. In this case, the app has to return to the login screen in order for the user to create a new account. The error from the server has the following message: {“error”:”ERROR_001”}, and as with the Response and Request, we will create an interceptor in order to catch all the incoming errors.

Looking at the documentation, we can see that the type for the error interceptor is also dynamic with the following specification:

To go to a different screen, since we are dealing with the first version of the app (read: something to be refactored later on), we directly navigate the user to another screen from this class using the Navigator widget with a GlobalKey.

dynamic errorInterceptor(DioError dioError) {
  if (dioError.message.contains("ERROR_001")) {
    // this will push a new route and remove all the routes that were present
    navigatorKey.currentState.pushNamedAndRemoveUntil(
        "/login", (Route<dynamic> route) => false);
  }
  
  return dioError;
}

Creating an Interceptor Class

Creating a function for each interceptor can be an acceptable approach, but what if we have more than one response interceptor? One approach that we can have is to create a class that extends the Interceptor class and overrides the onRequest, onError and onResponse methods.

class AppInterceptors extends Interceptor {
  @override
  FutureOr<dynamic> onRequest(RequestOptions options) async {
    if (options.headers.containsKey("requiresToken")) {
      //remove the auxiliary header
      options.headers.remove("requiresToken");

      SharedPreferences prefs = await SharedPreferences.getInstance();
      var header = prefs.get("Header");

      options.headers.addAll({"Header": "$header${DateTime.now()}"});

      return options;
    }
  }

  @override
  FutureOr<dynamic> onError(DioError dioError) {
    if (dioError.message.contains("ERROR_001")) {
      // this will push a new route and remove all the routes that were present
      navigatorKey.currentState.pushNamedAndRemoveUntil(
          "/login", (Route<dynamic> route) => false);
    }

    return dioError;
  }

  @override
  FutureOr<dynamic> onResponse(Response options) async {
    if (options.headers.value("verifyToken") != null) {
      //if the header is present, then compare it with the Shared Prefs key
      SharedPreferences prefs = await SharedPreferences.getInstance();
      var verifyToken = prefs.get("VerifyToken");

      // if the value is the same as the header, continue with the request
      if (options.headers.value("verifyToken") == verifyToken) {
        return options;
      }
    }

    return DioError(request: options.request, message: "User is no longer active");
  }
}

This class can then be easily added to the Dio object interceptors:

Dio addInterceptors(Dio dio) {
  dio.interceptors.add(AppInterceptors());
}

Putting it all together - Creating a simple Cache

One thing we can do with the interceptors is to create a simple cache for our requests. The concept is simple: if the user is using the app normally and for some reason there’s a network error or a connection-timeout, then we still want the user to see the feed or home page of the app. To do that, we have to save in memory all the requests that we are making, and, when verifying for the connection timeout error or an internal error from Dio (DioErrorType.DEFAULT), if there’s a request that we have saved for the same endpoint, with the same parameters, then we return the old response and warn the user that he’s offline at the moment.

import 'package:dio/dio.dart';

class CacheInterceptor extends Interceptor {
  CacheInterceptor();

  var _cache = new Map<Uri, Response>();

  @override
  onRequest(RequestOptions options) {
    return options;
  }

  @override
  onResponse(Response response) {
    _cache[response.request.uri] = response;
  }

  @override
  onError(DioError e) {
    print('onError: $e');
    if (e.type == DioErrorType.CONNECT_TIMEOUT || e.type == DioErrorType.DEFAULT) {
      var cachedResponse = _cache[e.request.uri];
      if (cachedResponse != null) {
        return cachedResponse;
      }
    }
    return e;
  }
}

We could further improve this cache by modifying our response and give it a parameter to warn the UI that this response is now a cache, or implement a persistent cache with SQL that would let the user see the previous feed if he opens the app offline, much like what the Linkedin app does.

Bonus Tip - Logging Everything Dio

For the majority of the time spent creating a project, we are going to stumble on errors upon errors when making API requests. Maybe we forgot a query parameter, or the header, or the body has a missing parameter or it’s just a case of the backend having a nasty bug. For all these cases, the best would be to have all the requests logged into our console so that we can easily check what has happened.

As with before, we create a new Interceptor class, implement all the necessary methods and log all the information that we want from the requests. Furthermore, since we want our logs to easily stand out, we might want to format them to always start and end with the following:

# REQUEST:
--> GET https://jsonplaceholder.typicode.com/todos/1
...
--> END GET
# RESPONSE:
<-- 200 https://jsonplaceholder.typicode.com/todos/1
...
<-- END HTTP
class LoggingInterceptors extends Interceptor {
  @override
  FutureOr<dynamic> onRequest(RequestOptions options) {
    print(
        "--> ${options.method != null ? options.method.toUpperCase() : 'METHOD'} ${"" + (options.baseUrl ?? "") + (options.path ?? "")}");
    print("Headers:");
    options.headers.forEach((k, v) => print('$k: $v'));
    if (options.queryParameters != null) {
      print("queryParameters:");
      options.queryParameters.forEach((k, v) => print('$k: $v'));
    }
    if (options.data != null) {
      print("Body: ${options.data}");
    }
    print(
        "--> END ${options.method != null ? options.method.toUpperCase() : 'METHOD'}");

    return options;
  }

  @override
  FutureOr<dynamic> onError(DioError dioError) {
    print(
        "<-- ${dioError.message} ${(dioError.response?.request != null ? (dioError.response.request.baseUrl + dioError.response.request.path) : 'URL')}");
    print(
        "${dioError.response != null ? dioError.response.data : 'Unknown Error'}");
    print("<-- End error");
  }

  @override
  FutureOr<dynamic> onResponse(Response response) {
    print(
        "<-- ${response.statusCode} ${(response.request != null ? (response.request.baseUrl + response.request.path) : 'URL')}");
    print("Headers:");
    response.headers?.forEach((k, v) => print('$k: $v'));
    print("Response: ${response.data}");
    print("<-- END HTTP");
  }
}

Testing it out in with the Json Placeholder website, we get the following logged:

—> GET https:///jsonplaceholder.typicode.com/todos/1/
Headers:
requiresToken: true
queryParameters:
—> END GET
<— 200 https:///jsonplaceholder.typicode.com/todos/1/
Headers:
connection: [keep-alive]
set-cookie: [__cfduid=dd3fb888c5f062dd954e06e2e4c1166241567679659; expires=Fri, 04-Sep-20 10:34:19 GMT; path=/; domain=.typicode.com; HttpOnly]
cache-control: [public, max-age=14400]
transfer-encoding: [chunked]
date: [Thu, 05 Sep 2019 10:34:19 GMT]
content-encoding: [gzip]
vary: [Origin, Accept-Encoding]
age: [4045]
cf-cache-status: [HIT]
expect-ct: [max-age=604800, report-uri=“https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct”]
content-type: [application/json; charset=utf-8]
pragma: [no-cache]
server: [cloudflare]
x-powered-by: [Express]
access-control-allow-credentials: [true]
cf-ray: [51178cd29f9b724b-AMS]
etag: [W/“53-hfEnumeNh6YirfjyjaujcOPPT+s”]
via: [1.1 vegur]
x-content-type-options: [nosniff]
expires: [Thu, 05 Sep 2019 14:34:19 GMT]
Response: {userId: 1, id: 1, title: delectus aut autem, completed: false}
<— END HTTP

As a further note, we are currently using the print method for displaying the logs. This might not be ideal since these logs will also appear in the production app, which means that anyone who connects the phone with this app open and runs flutter logs will be able to see the full output. As a better alternative, we can use debugPrint with Product Flavors as seen in this article - debugPrint and the power of hiding and customizing your logs in Dart.


And that’s it 👻 with this we have at least mastered the basics of Interceptors in Dio and can now add more functionality, logging and better error handling to our apps.

As always, please do tell me on Twitter what are going to be your uses for them!

Want to get the latest articles and news? Subscribe to the newsletter here 👇

Diving Into Flutter

In Diving into Flutter we will discover the Flutter Framework, bit by bit.

From the BuildContext, to pubspec files, going through exotic backend solutions, there won't be a stone left unturned.

Each week I'll also share one or two links for interesting resources I found around the Web

And for other articles, check the rest of the blog! Blog - Gonçalo Palma

Want to get the latest articles and news? Subscribe to Diving Into Flutter

Hey! I'm Gonçalo Palma! 👋

I often share Deep Diving articles on Flutter 💙