Marko
Marko

Reputation: 71

Flutter Dio not re-calling POST method with file upload after token refresh in Interceptor's onError method

So, I have an interceptor set for api calls. It looks like this:

class AuthorizationInterceptor extends Interceptor {
  @override
  void onRequest(
      RequestOptions options, RequestInterceptorHandler handler) async {
    if (options.headers.containsKey('requiresToken') &&
        options.headers['requiresToken'] == false) {
      options.headers.remove('requiresToken');

      super.onRequest(options, handler);
    } else {
      String token = await SecureStorage.loadAccessToken();

      options.headers['Authorization'] = 'Bearer $token';
      // options.headers['Content-Type'] = 'application/json';

      super.onRequest(options, handler);
    }
  }

  @override
  void onError(DioError err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401) {
      log('++++++ interceptor error ++++++');

      if (await SecureStorage.loadAccessToken() == '') {
        super.onError(err, handler);
        return;
      }

      bool isTokenRefreshed = await AuthApi.refreshToken();

      if (isTokenRefreshed) {
        RequestOptions origin = err.response!.requestOptions;

        String token = await SecureStorage.loadAccessToken();
        origin.headers["Authorization"] = "Bearer $token";

        try {
          final Response response = await DioClient.request(
            url: origin.path,
            data: origin.data,
            options: Options(
              headers: origin.headers,
              method: origin.method,
            ),
          );

          handler.resolve(response);
        } catch (e) {
          super.onError(err, handler);
        }
      }
    } else {
      super.onError(err, handler);
      return;
    }
  }
}

Now, when I'm calling some api with dio GET method and the token is expired, onError interceptor handles 401 and refreshes the token. After that the request that was previously called continues and everything finishes fine.

But, when I try to do the exact thing using dio POST it doesn't work as expected. If there's a 401 response code it should go through onError and refresh the token and then continue to call previously called POST function which looks like this:

static Future uploadImage(PlatformFile image, String disclaimer,
      {String? imageTitle}) async {
    String imageExtension = image.extension!;
    String imageName = '${imageTitle ?? 'image'}.$imageExtension';

    final formData = FormData.fromMap({
      'upload_file': MultipartFile.fromBytes(
        image.bytes!,
        filename: imageName,
        contentType: MediaType('media_content', imageExtension),
      ),
      'disclaimer': disclaimer,
    });

    try {
      final response = await DioClient.post(
        url: Endpoint.images,
        data: formData,
        options: Options(
          headers: {
            'Content-Type': 'multipart/form-data',
          },
        ),
      );

      return response.data;
    } on DioError catch (err) {
      ToastMessage.apiError(err);
      log('DioError uploadImage response: ${ToastMessage.message}');
    }
  }

This is one of the functions, as many others I use, that works fine:

 static Future getPosts(
      {required int page,
      int? pageSize,
      String? searchParam,
      String? status,
      String? categoryId}) async {
    try {
      final response = await DioClient.get(
        url: Endpoint.getPosts,
        query: {
          'page': page,
          if (pageSize != null) 'page_size': pageSize,
          if (status != null) 'status': status,
          if (searchParam != null) 'search_param': searchParam,
          if (categoryId != null) 'category_id': categoryId,
        },
      );

      return response.data;
    } on DioError catch (err) {
      ToastMessage.apiError(err);
      log('DioError get posts response: ${ToastMessage.message}');
    }
  }

I tried everything so far. Everything I do looks like this:

When calling dio GET functions and the response is 401 this is the flow in logs:

When calling dio POST (above uploadImage function):

So, my question would probably be:

Why is onError of the DioError interceptor not called if the response code is 401 in POST function but is called in GET functions?

UPDATE:

When 401 is the response of the uploadImage function this is the flow:

In my IDE's logs I see that the call in this try block is done but in my browser's network inspection nothing happens. I just have 401 response from uploadImage BE and 200 response for refresh token response and no retried uploadImage call.

UPDATE 2:

My issue is the same as described here

Upvotes: 1

Views: 1594

Answers (3)

Ole Spaarmann
Ole Spaarmann

Reputation: 16761

There is a new .clone() method on MultipartFile that was introduced for exactly this case.

This is how I solved this issue for myself. I created an Api Client class that I use in my repository classes and that uses dio interceptors to renew auth tokens.


// This is the function I call in the dio interceptor in the onError case
// You will most likely have to adjust this to your own needs
Future<void> _refreshAndRedoRequest(
    DioException exception, ErrorInterceptorHandler handler) async {
  try {
    logger.d('Refreshing token and retrying request');
    // This is my custom method to renew the tokens. You can replace this with your own.
    await authStateNotifier.renewTokens();
    _setAuthHeader(exception.requestOptions);

    // Clone the original request
    final requestOptions = exception.requestOptions;
    final requestClone = requestOptions.copyWith();

    // We are going to store the formData in here
    final dynamic retryFormData;

    // Check if the request is a file upload
    if (requestOptions.data is FormData) {
      // Get the FormData instance from options.data
      final oldFormData = requestOptions.data as FormData;

      // Create a new FormData instance
      final newFormData = FormData();

      // Clone the fields
      newFormData.fields.addAll(oldFormData.fields);

      // Clone the files
      for (var fileEntry in oldFormData.files) {
        newFormData.files.add(MapEntry(
          fileEntry.key,
          // This is necessary because we otherwise get a "Bad state: Can't finalize a finalized MultipartFile" error
          // See: https://github.com/cfug/dio/issues/482
          // This is the clone method I am talking about.
          fileEntry.value.clone(),
        ));
      }

      retryFormData = newFormData;
    } else {
      // Not a file upload, just copy the old request data
      retryFormData = requestClone.data;
    }

    // Manually send the request again
    try {
      // Create a new Dio instance for the retried request
      // This is necessary because otherwise if there is an error in the retry
      // the request and all future requests will be stuck unless you restart the app.
      final dioForRetry = Dio(baseOptions);

      final response = await dioForRetry.request(
        requestClone.path,
        cancelToken: requestClone.cancelToken,
        data: retryFormData,
        onReceiveProgress: requestClone.onReceiveProgress,
        onSendProgress: requestClone.onSendProgress,
        queryParameters: requestClone.queryParameters,
        options: Options(
          method: requestClone.method,
          headers: requestClone.headers,
          contentType: requestClone.contentType,
        ),
      );

      // If the request is successful, resolve it
      handler.resolve(response);
    } catch (e) {
      logger.d('Error in retrying request: $e');
      // If the request fails, reject it. You might want to 
      // not only use 401 as statusCode
      final exception = DioException.badResponse(
          statusCode: 401,
          requestOptions: RequestOptions(),
          response: Response(requestOptions: RequestOptions()));
      handler.reject(exception);
    }
  } catch (e) {
    logger.d('Error in renewing tokens: $e');
    final exception = DioException.badResponse(
        statusCode: 401,
        requestOptions: RequestOptions(),
        response: Response(requestOptions: RequestOptions()));
    handler.reject(exception);
  }
}

Upvotes: 1

Marko
Marko

Reputation: 71

After some research I found out that multipart files are Stream and they need to be re-instantiated when retrying an API call after token refresh.

So, I managed to solve my problem and these are updated functions in case someone else stumble upon this problem.

static Future uploadImage(PlatformFile image, String disclaimer,
      {String? imageTitle}) async {
    String imageExtension = image.extension!;
    String imageName = '${imageTitle ?? 'image'}.$imageExtension';

    final formData = FormData.fromMap({
      'upload_file': MultipartFile.fromBytes(
        image.bytes!,
        filename: imageName,
        contentType: MediaType('media_content', imageExtension),
      ),
      'disclaimer': disclaimer,
    });

    try {
      final response = await DioClient.post(
        url: Endpoint.images,
        data: formData,
        options: Options(
          headers: {
            'Content-Type': 'multipart/form-data',
          },
          // Added this extra key to send image data in the request body
          extra: {
            'image': {
              'imageBytes': image.bytes,
              'filename': imageName,
              'imageExtension': imageExtension,
              'disclaimer': disclaimer,
            },
          },
        ),
      );

      return response.data;
    } on DioError catch (err) {
      ToastMessage.apiError(err);
      log('DioError uploadImage response: ${ToastMessage.message}');
    }
  }

Then this is new interceptor onError part:

class AuthorizationInterceptor extends Interceptor {
  
  // onRequest

  @override
  void onError(DioError err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401) {
      log('++++++ interceptor error ++++++');

      if (await SecureStorage.loadAccessToken() == '') {
        super.onError(err, handler);
        return;
      }

      bool isTokenRefreshed = await AuthApi.refreshToken();

      if (isTokenRefreshed) {
        RequestOptions origin = err.requestOptions;

        String token = await SecureStorage.loadAccessToken();
        origin.headers["Authorization"] = "Bearer $token";

        final Options options = Options(
          method: origin.method,
          headers: origin.headers,
        );

        try {
          // If the request is a file upload, we need to re-initialize the file
          if (origin.extra.containsKey('image')) {
            origin.data =
                FormDataHandler.uploadImageData(origin.extra['image']);
          }

          final Response retryResponse = await DioClient.request(
            url: origin.path,
            data: origin.data,
            query: origin.queryParameters,
            options: options,
          );

          return handler.resolve(retryResponse);
        } catch (e) {
          super.onError(err, handler);
        }
      }
    } else {
      super.onError(err, handler);
      return;
    }
  }
}

The file that keeps FormDataHandler:

class FormDataHandler {
  static FormData uploadImageData(Map<String, dynamic> imageData) {
    return FormData.fromMap({
      'upload_file': MultipartFile.fromBytes(
        imageData['imageBytes'],
        filename: imageData['filename'],
        contentType: MediaType('media_content', imageData['imageExtension']),
      ),
      'disclaimer': imageData['disclaimer'],
    });
  }
}

Upvotes: 1

I'm not sure but I've just checked my implementation on 401 handling and I use:

RequestOptions origin = err.requestOptions;

instead:

RequestOptions origin = err.response!.requestOptions;

here is part from my code

 final Options newOptions = Options(
      method: err.requestOptions.method,
      headers: headers,
    );

    try {
      final Response<dynamic> newResponse = await _dio.request(
        err.requestOptions.path,
        data: err.requestOptions.data,
        queryParameters: err.requestOptions.queryParameters,
        options: newOptions,
      );

      handler.resolve(newResponse);
    } on DioError catch (err) {
      handler.next(err);
    }

Upvotes: 0

Related Questions