Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

QueuedInterceptors does not work with parallel requests #2342

Closed
Nicholas86 opened this issue Dec 12, 2024 · 11 comments
Closed

QueuedInterceptors does not work with parallel requests #2342

Nicholas86 opened this issue Dec 12, 2024 · 11 comments
Labels
h: need extra help Extra help is needed h: need triage This issue needs to be categorized s: bug Something isn't working

Comments

@Nicholas86
Copy link

Nicholas86 commented Dec 12, 2024

Package

dio

Version

5.7.0

Operating-System

Android, iOS, Web, MacOS, Linux, Windows

Adapter

Default Dio

Output of flutter doctor -v

No response

Dart Version

No response

Steps to Reproduce

// ignore: dangling_library_doc_comments
/// CSRF Token Example
///
/// Add interceptors to handle CSRF token.
/// - token update
/// - retry policy
///
/// Scenario:
/// 1. Client access to the Server by using `GET` method.
/// 2. Server generates CSRF token and sends it to the client.
/// 3. Client make a request to the Server by using `POST` method with the CSRF token.
/// 4. If the CSRF token is invalid, the Server returns 401 status code.
/// 5. Client requests a new CSRF token and retries the request.
import 'dart:developer';

import 'package:dio/dio.dart';

void main() async {
  /// HTML example:
  /// ``` html
  /// <input type="hidden" name="XSRF_TOKEN" value=${cachedCSRFToken} />
  /// ```
  const String cookieKey = 'XSRF_TOKEN';

  /// Header key for CSRF token
  const String headerKey = 'X-Csrf-Token';

  String? cachedCSRFToken;

  void printLog(
    int index,
    String path,
  ) =>
      log(
        '''
#$index
- Path: '$path'
- CSRF Token: $cachedCSRFToken
''',
        name: 'queued_interceptor_csrftoken.dart',
      );

  final dio = Dio()
    ..options.baseUrl = 'https://httpbun.com/'
    ..interceptors.addAll(
      [
        /// Handles CSRF token
        QueuedInterceptorsWrapper(
          /// Adds CSRF token to headers, if it exists
          onRequest: (requestOptions, handler) {
            print(
              '---😆😆QueuedInterceptorsWrapper00,onRequest, cachedCSRFToken:$cachedCSRFToken,requestOptions: ${requestOptions.headers}😆😆---',
            );
            if (cachedCSRFToken != null) {
              requestOptions.headers[headerKey] = cachedCSRFToken;
              requestOptions.headers['Set-Cookie'] = '$cookieKey=$cachedCSRFToken';
            }
            print('---😆😆QueuedInterceptorsWrapper01,onRequest, cachedCSRFToken:$cachedCSRFToken,'
                'requestOptions.headers: ${requestOptions.headers} 😆😆\n---');
            return handler.next(requestOptions);
          },

          /// Update CSRF token from [response] headers, if it exists
          onResponse: (response, handler) {
            print('---😆😆QueuedInterceptorsWrapper00,onResponse, response.data:${response.data}, '
                'response.data:${response.statusCode}😆😆---');
            final token = response.headers.value(headerKey);

            if (token != null) {
              cachedCSRFToken = token;
            }
            print(
                '---😆😆QueuedInterceptorsWrapper01, onResponse,cachedCSRFToken: $cachedCSRFToken, response.data:${response.data},'
                    ' response.data:${response.statusCode}😆😆---');
            return handler.resolve(response);
          },

          onError: (error, handler) async {
            // print('---😆😆QueuedInterceptorsWrapper00, onError, error:${error.response?.statusCode}😆😆---');
            // 响应报文为null, 将错误error转发给下一个拦截器,下一个拦截器的onError会收到
            if (error.response == null) {
              return handler.next(error);
            }

            /*
            拉取新的token
             */
            /// When request fails with 401 status code, request new CSRF token
            if (error.response?.statusCode == 401) {
              print('---😆😆QueuedInterceptorsWrapper01, onError, 拉取新的token, '
                  'statusCode:${error.response?.statusCode} 😆😆---');
              try {
                final tokenDio = Dio(
                  BaseOptions(baseUrl: error.requestOptions.baseUrl),
                );

                /// Generate CSRF token
                ///
                /// This is a MOCK REQUEST to generate a CSRF token.
                /// In a real-world scenario, this should be generated by the server.
                final result = await tokenDio.post(
                  '/response-headers',
                  queryParameters: {
                    headerKey: '94d6d1ca-fa06-468f-a25c-2f769d04c26c',
                  },
                );

                // 拉取新token异常
                if (result.statusCode == null || result.statusCode! ~/ 100 != 2) {
                  throw DioException(requestOptions: result.requestOptions);
                }

                final updatedToken = result.headers.value(headerKey);
                print('---😆😆QueuedInterceptorsWrapper01, onError, 拉取新的token, '
                    'updatedToken:${updatedToken} 😆😆\n---');
                if (updatedToken == null) {
                  throw ArgumentError.notNull(headerKey);
                }

                // 新的token已获取, 缓存起来
                cachedCSRFToken = updatedToken;

                // 将错误error转发给下一个拦截器,下一个拦截器的onError会收到
                return handler.next(error);
              } on DioException catch (e) {
                // 拉取token异常后,以[error]作为结果,通过拒绝来完成请求。
                // 那么e就不会转发到下一个拦截器, 下一个拦截器的onError就接收不到e了
                // 双token认证, 上层业务catchError到e,去登录页面
                return handler.reject(e);
              }
            }
          },
        ),

        /// Retry the request when 401 occurred
        /// 401发生时, 重新发起本次请求(比如调起获取用户信息接口, 401了. 上面刷新token也成功了)
        QueuedInterceptorsWrapper(
          onError: (error, handler) async {
            /*
            上面的error.response不为null且是401错误, 重新调起获取用户信息接口
             */
            print('---😆😆QueuedInterceptorsWrapper10, onError, error:${error.response?.statusCode},'
                'cachedCSRFToken:${cachedCSRFToken}😆😆---');

            if (error.response != null && error.response!.statusCode == 401) {
              final retryDio = Dio(
                BaseOptions(baseUrl: error.requestOptions.baseUrl),
              );

              // 将新的token放到headers里面
              if (error.requestOptions.headers.containsKey(headerKey) &&
                  error.requestOptions.headers[headerKey] != cachedCSRFToken) {
                error.requestOptions.headers[headerKey] = cachedCSRFToken;
              }

              /// In real-world scenario,
              /// the request should be requested with [error.requestOptions]
              /// using [fetch] method.
              /// ``` dart
              /// final result = await retryDio.fetch(error.requestOptions);
              /// ```
              /// 在现实世界中,
              // 请求应该被请求[error.requestOptions]
              // 使用[fetch]方法。
              //  ``` ``
              // 最终结果= await retryDio.fetch(error.requestOptions);
              // ' ' '
              final result = await retryDio.get('/mix/s=200');
              print(
                  '---😆😆QueuedInterceptorsWrapper11, onError, result:$result, response.headers:${result.headers}'
                      '😆😆\n---');

              // 将获取用户信息返回给上层业务/或者转发给下一个缓存拦截器
              return handler.resolve(result);
            }
          },
        ),
      ],
    );

  /// Make Requests
  printLog(0, 'initial');

  /// #1 Access to the Server
  // final accessResult = await dio.get(
  //   '/response-headers',
  //
  //   /// Pretend the Server has generated CSRF token
  //   /// and passed it to the client.
  //   queryParameters: {
  //     headerKey: 'fbf07f2b-b957-4555-88a2-3d3e30e5fa64',
  //   },
  // );
  // printLog(1, accessResult.realUri.path);

  /// #2 Make a request(POST) to the Server
  ///
  /// Pretend the token has expired.
  ///
  /// Then the interceptor will request a new CSRF token
  // final createResult = await dio.post(
  //   '/mix/s=401/',
  // );
  // printLog(2, createResult.realUri.path);

  dio
      .post(
    '/mix/s=401/',
  )
      .then((value) {
    print('---😆😆请求结果0,value:$value');
  });

  dio
      .post(
    '/mix/s=401/',
  )
      .then((value) {
    print('---😆😆请求结果1,value:$value');
  });

  // try {
  //   await Future.wait([
  //     dio.post(
  //       '/mix/s=401/',
  //     ),
  //     dio.post(
  //       '/mix/s=401/',
  //     ),
  //     // dio.get('/201'),
  //   ]);
  // } catch (e) {
  //   print(e);
  // }
}

Expected Result

第一个请求刷新令牌后,第二个请求可以直接使用

Actual Result

暂无.恶心的 QueuedInterceptorsWrapper无效果,跟文档说明的不一样

@Nicholas86 Nicholas86 added h: need triage This issue needs to be categorized s: bug Something isn't working labels Dec 12, 2024
@AlexV525
Copy link
Member

生气,然后呢,有事说事就可以了,还得照顾你的情绪呗,开源变成情绪大乱斗了?

@seunghwanly Do you have insights about making parallel requests work with the queued interceptor? The provided code is based on the example: https://github.com/cfug/dio/blob/main/example_dart/lib/queued_interceptor_crsftoken.dart

@AlexV525 AlexV525 added the h: need extra help Extra help is needed label Dec 12, 2024
@AlexV525 AlexV525 changed the title QueuedInterceptorsWrapper在多个请求时,每个请求都会执行刷新令牌操作。也就是所有请求都会执行onRequest,在第二个请求里,cachedCSRFToken != null依然为null。我很生气,QueuedInterceptorsWrapper功能跟文档不符合 QueuedInterceptors does not work with parallel requests Dec 12, 2024
@seunghwanly
Copy link
Contributor

生气,然后呢,有事说事就可以了,还得照顾你的情绪呗,开源变成情绪大乱斗了?

@seunghwanly Do you have insights about making parallel requests work with the queued interceptor? The provided code is based on the example: https://github.com/cfug/dio/blob/main/example_dart/lib/queued_interceptor_crsftoken.dart

I'll come up with some parallel examples soon.

@AlexV525
Copy link
Member

I'll come up with some parallel examples soon.

Cool! Thanks for the update.

@seunghwanly
Copy link
Contributor

@Nicholas86

The QueuedInterceptor appears to be working as intended. I created a test to verify whether tasks are processed sequentially, and the console output confirms that the token is updated in the correct order. Please note that this example focuses specifically on sequential processing and does not include additional token handling logic, such as comparing updatedAt or managing token states. I hope this example is helpful to you :)

Code sample

import 'dart:convert';
import 'dart:math';

import 'package:dio/dio.dart';

/// Pretend as Authentication Server that generates access token and refresh token
class AuthenticationServer {
  static Map<String, String> generate() => <String, String>{
        'access_token': _generateUuid(),
        'refresh_token': _generateUuid(),
      };

  static String _generateUuid() {
    final random = Random.secure();
    final bytes = List<int>.generate(8, (_) => random.nextInt(256));
    return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
  }
}

typedef TokenHistory = ({
  String? previous,
  String? current,
  DateTime updatedAt,
  int updatedBy,
});

class TokenManager {
  static String? _accessToken;

  String? get accessToken => _accessToken;

  static final List<TokenHistory> _history = <TokenHistory>[];

  void setAccessToken(String? token, int instanceId) {
    final previous = _accessToken;
    _accessToken = token;
    _history.add(
      (
        previous: previous,
        current: _accessToken,
        updatedAt: DateTime.now(),
        updatedBy: instanceId,
      ),
    );
  }

  void printHistory() {
    print('=== Token History ===');
    for (int i = 0; i < _history.length; i++) {
      final entry = _history[i];
      print('''
[$i]\tupdated token: ${entry.previous} → ${entry.current}
\tupdated at: ${entry.updatedAt.toIso8601String()}
\tupdated by: ${entry.updatedBy}
      ''');
    }
  }
}

void main() async {
  final tokenManager = TokenManager();

  final dio = Dio(
    BaseOptions(
      baseUrl: 'https://httpbun.com/',
    ),
  );

  dio.interceptors.add(
    QueuedInterceptorsWrapper(
      onRequest: (requestOptions, handler) {
        final requestNumber = requestOptions.hashCode;
        print(
          '''
[onRequest] ${requestOptions.hashCode} / time: ${DateTime.now().toIso8601String()}
\tPath: ${requestOptions.path}
\tHeaders: ${requestOptions.headers}
          ''',
        );

        if (tokenManager.accessToken != null) {
          requestOptions.headers['Authorization'] =
              'Bearer ${tokenManager.accessToken}';
        }

        return handler.next(requestOptions);
      },
      onResponse: (response, handler) {
        print('''
[onResponse] ${response.requestOptions.hashCode} / time: ${DateTime.now().toIso8601String()}
\tStatus: ${response.statusCode}
\tData: ${response.data}
        ''');

        return handler.resolve(response);
      },
      onError: (error, handler) async {
        final statusCode = error.response?.statusCode;
        print(
          '''
[onError] ${error.requestOptions.hashCode} / time: ${DateTime.now().toIso8601String()}
\tStatus: $statusCode
          ''',
        );

        /// Only handles 401 in this example
        if (statusCode != 401) {
          return handler.resolve(error.response!);
        }

        final tokenRefreshDio = Dio()..options.baseUrl = 'https://httpbun.com/';

        final response = await tokenRefreshDio.post(
          'https://httpbun.com/mix/s=201/b64=${base64.encode(jsonEncode(AuthenticationServer.generate()).codeUnits)}',
        );
        if (response.statusCode == null || response.statusCode! ~/ 100 != 2) {
          return handler.reject(error);
        }

        final body = jsonDecode(response.data) as Map<String, Object?>;
        if (!body.containsKey('access_token')) {
          return handler.reject(error);
        }

        final token = body['access_token'] as String;
        tokenManager.setAccessToken(token, error.requestOptions.hashCode);
        tokenRefreshDio.close();

        /// Pretend authorization has been resolved and try again
        final retried = await dio.fetch(
          error.requestOptions
            ..path = '/mix/s=200'
            ..headers = {
              'Authorization': 'Bearer ${tokenManager.accessToken}',
            },
        );

        if (retried.statusCode == null || retried.statusCode! ~/ 100 != 2) {
          return handler.reject(error);
        }

        return handler.resolve(error.response!);
      },
    ),
  );

  await Future.wait([
    dio.post('/mix/s=401'),
    dio.post('/mix/s=401'),
    dio.post('/mix/s=200'),
  ]);

  tokenManager.printHistory();
}

Console(output)

[onRequest] 388747248 / time: 2024-12-20T01:26:04.617883
	Path: /mix/s=401
	Headers: {}
          
[onRequest] 731513614 / time: 2024-12-20T01:26:04.619478
	Path: /mix/s=401
	Headers: {}
          
[onRequest] 1229983 / time: 2024-12-20T01:26:04.619514
	Path: /mix/s=200
	Headers: {}
          
[onResponse] 1229983 / time: 2024-12-20T01:26:05.099799
	Status: 200
	Data: 
        
[onError] 731513614 / time: 2024-12-20T01:26:05.102982
	Status: 401
          
[onRequest] 731513614 / time: 2024-12-20T01:26:05.549759
	Path: /mix/s=200
	Headers: {Authorization: Bearer 9f0ef79368ea8b47}
          
[onResponse] 731513614 / time: 2024-12-20T01:26:05.690177
	Status: 200
	Data: 
        
[onError] 388747248 / time: 2024-12-20T01:26:05.690450
	Status: 401
          
[onRequest] 388747248 / time: 2024-12-20T01:26:06.114056
	Path: /mix/s=200
	Headers: {Authorization: Bearer e2d04742db8f8954}
          
[onResponse] 388747248 / time: 2024-12-20T01:26:06.255467
	Status: 200
	Data: 
        
=== Token History ===
[0]	updated token: null → 9f0ef79368ea8b47
	updated at: 2024-12-20T01:26:05.546387
	updated by: 731513614
      
[1]	updated token: 9f0ef79368ea8b47 → e2d04742db8f8954
	updated at: 2024-12-20T01:26:06.113834
	updated by: 388747248
      

Process finished with exit code 0

@Nicholas86
Copy link
Author

牛儿逼之。tanks!!!

@Passer-by
Copy link
Contributor

@seunghwanly

  await Future.wait([
    dio.post('/mix/s=401'),
    dio.post('/mix/s=401'),
    dio.post('/mix/s=401'),
    dio.post('/mix/s=401'),
    dio.post('/mix/s=200'),
  ]); 

Result:

[onRequest] 1014527542 / time: 2024-12-20T13:41:05.116733
	Path: /mix/s=401
	Headers: {}
          
[onRequest] 540799293 / time: 2024-12-20T13:41:05.118625
	Path: /mix/s=401
	Headers: {}
          
[onRequest] 224378376 / time: 2024-12-20T13:41:05.118664
	Path: /mix/s=401
	Headers: {}
          
[onRequest] 994945721 / time: 2024-12-20T13:41:05.118692
	Path: /mix/s=401
	Headers: {}
          
[onRequest] 1047915714 / time: 2024-12-20T13:41:05.118716
	Path: /mix/s=200
	Headers: {}
          
[onError] 540799293 / time: 2024-12-20T13:41:05.966958
	Status: 401
          
[onResponse] 1047915714 / time: 2024-12-20T13:41:05.970819
	Status: 200
	Data: 
        
[onRequest] 540799293 / time: 2024-12-20T13:41:06.540820
	Path: /mix/s=200
	Headers: {Authorization: Bearer b0180a6aa753f1f3}
          
[onResponse] 540799293 / time: 2024-12-20T13:41:06.712329
	Status: 200
	Data: 
        
[onError] 224378376 / time: 2024-12-20T13:41:06.712676
	Status: 401
          
[onRequest] 224378376 / time: 2024-12-20T13:41:07.241854
	Path: /mix/s=200
	Headers: {Authorization: Bearer 01049158f2990419}
          
[onResponse] 224378376 / time: 2024-12-20T13:41:07.410081
	Status: 200
	Data: 
        
[onError] 1014527542 / time: 2024-12-20T13:41:07.410120
	Status: 401
          
[onRequest] 1014527542 / time: 2024-12-20T13:41:07.909664
	Path: /mix/s=200
	Headers: {Authorization: Bearer ecb224773facff4b}
          
[onResponse] 1014527542 / time: 2024-12-20T13:41:08.107127
	Status: 200
	Data: 
        
[onError] 994945721 / time: 2024-12-20T13:41:08.107218
	Status: 401
          
[onRequest] 994945721 / time: 2024-12-20T13:41:08.625902
	Path: /mix/s=200
	Headers: {Authorization: Bearer 11d7382513fa497b}
          
[onResponse] 994945721 / time: 2024-12-20T13:41:08.786969
	Status: 200
	Data: 
        
=== Token History ===
[0]	updated token: null → b0180a6aa753f1f3
	updated at: 2024-12-20T13:41:06.537652
	updated by: 540799293
      
[1]	updated token: b0180a6aa753f1f3 → 01049158f2990419
	updated at: 2024-12-20T13:41:07.241490
	updated by: 224378376
      
[2]	updated token: 01049158f2990419 → ecb224773facff4b
	updated at: 2024-12-20T13:41:07.909509
	updated by: 1014527542
      
[3]	updated token: ecb224773facff4b → 11d7382513fa497b
	updated at: 2024-12-20T13:41:08.625487
	updated by: 994945721

We should consider more complex concurrency scenarios, and check if the token has changed before and after the refresh.

@Passer-by
Copy link
Contributor

Passer-by commented Dec 20, 2024

@seunghwanly I made a small modification to the onError callback.

Code:

onError: (error, handler) async {
        final statusCode = error.response?.statusCode;
        print(
          '''
[onError] ${error.requestOptions.hashCode} / time: ${DateTime.now().toIso8601String()}
\tStatus: $statusCode
          ''',
        );

        /// Only handles 401 in this example
        if (statusCode != 401) {
          return handler.resolve(error.response!);
        }

        final requestToken = error.requestOptions.headers['Authorization'];
        if (requestToken == tokenManager.accessToken) {
          final tokenRefreshDio = Dio()
            ..options.baseUrl = 'https://httpbun.com/';

          final response = await tokenRefreshDio.post(
            'https://httpbun.com/mix/s=201/b64=${base64.encode(jsonEncode(AuthenticationServer.generate()).codeUnits)}',
          );
          if (response.statusCode == null || response.statusCode! ~/ 100 != 2) {
            return handler.reject(error);
          }

          final body = jsonDecode(response.data) as Map<String, Object?>;
          if (!body.containsKey('access_token')) {
            return handler.reject(error);
          }

          final token = body['access_token'] as String;
          tokenManager.setAccessToken(token, error.requestOptions.hashCode);
          tokenRefreshDio.close();
        }

        /// Pretend authorization has been resolved and try again
        final retried = await dio.fetch(
          error.requestOptions
            ..path = '/mix/s=200'
            ..headers = {
              'Authorization': 'Bearer ${tokenManager.accessToken}',
            },
        );

        if (retried.statusCode == null || retried.statusCode! ~/ 100 != 2) {
          return handler.reject(error);
        }

        return handler.resolve(error.response!);
      },

Test:

  await Future.wait([
    dio.post('/mix/s=401'),
    dio.post('/mix/s=401'),
    dio.post('/mix/s=401'),
    dio.post('/mix/s=401'),
    dio.post('/mix/s=200'),
  ]);

Result:

  [onRequest] 1036461266 / time: 2024-12-20T13:57:06.512203
	Path: /mix/s=401
	Headers: {}
          
[onRequest] 595695012 / time: 2024-12-20T13:57:06.513925
	Path: /mix/s=401
	Headers: {}
          
[onRequest] 900364002 / time: 2024-12-20T13:57:06.513960
	Path: /mix/s=401
	Headers: {}
          
[onRequest] 728961038 / time: 2024-12-20T13:57:06.513984
	Path: /mix/s=401
	Headers: {}
          
[onRequest] 400647909 / time: 2024-12-20T13:57:06.514006
	Path: /mix/s=200
	Headers: {}
          
[onError] 900364002 / time: 2024-12-20T13:57:07.166490
	Status: 401
          
[onResponse] 400647909 / time: 2024-12-20T13:57:07.174784
	Status: 200
	Data: 
        
[onRequest] 900364002 / time: 2024-12-20T13:57:07.783431
	Path: /mix/s=200
	Headers: {Authorization: Bearer 3d2250e3aab93a54}
          
[onResponse] 900364002 / time: 2024-12-20T13:57:07.982521
	Status: 200
	Data: 
        
[onError] 595695012 / time: 2024-12-20T13:57:07.983442
	Status: 401
          
[onRequest] 595695012 / time: 2024-12-20T13:57:07.983841
	Path: /mix/s=200
	Headers: {Authorization: Bearer 3d2250e3aab93a54}
          
[onResponse] 595695012 / time: 2024-12-20T13:57:08.184071
	Status: 200
	Data: 
        
[onError] 728961038 / time: 2024-12-20T13:57:08.184240
	Status: 401
          
[onRequest] 728961038 / time: 2024-12-20T13:57:08.184468
	Path: /mix/s=200
	Headers: {Authorization: Bearer 3d2250e3aab93a54}
          
[onResponse] 728961038 / time: 2024-12-20T13:57:08.381223
	Status: 200
	Data: 
        
[onError] 1036461266 / time: 2024-12-20T13:57:08.381375
	Status: 401
          
[onRequest] 1036461266 / time: 2024-12-20T13:57:08.381676
	Path: /mix/s=200
	Headers: {Authorization: Bearer 3d2250e3aab93a54}
          
[onResponse] 1036461266 / time: 2024-12-20T13:57:08.587818
	Status: 200
	Data: 
        
=== Token History ===
[0]	updated token: null → 3d2250e3aab93a54
	updated at: 2024-12-20T13:57:07.775799
	updated by: 900364002
  

@seunghwanly
Copy link
Contributor

@Passer-by Thank you for providing such a detailed example! Comparing the current token with the saved one can indeed help reduce the cost associated with token updates. 👍

@Passer-by
Copy link
Contributor

@seunghwanly Are you interested in updating this example? queued_interceptor_crsftoken
Thank you~~~~

@seunghwanly
Copy link
Contributor

@seunghwanly Are you interested in updating this example? queued_interceptor_crsftoken Thank you~~~~

Sure I'll update and create a pull request this week! Thanks for your support :)

@Nicholas86
Copy link
Author

@Passer-by Thank you for providing such a detailed example! Comparing the current token with the saved one can indeed help reduce the cost associated with token updates. 👍

牛而逼之。解决了并发状态下,频繁刷新token的问题。I can't express my gratitude!!!
这里有个之前的解决方案
https://stackoverflow.com/questions/76228296/dio-queuedinterceptor-to-handle-refresh-token-with-multiple-requests/76406130#76406130

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
h: need extra help Extra help is needed h: need triage This issue needs to be categorized s: bug Something isn't working
Projects
None yet
Development

No branches or pull requests

4 participants