├── lib ├── rate_limiter.dart └── src │ ├── extension.dart │ ├── backoff.dart │ ├── throttle.dart │ └── debounce.dart ├── CHANGELOG.md ├── example ├── pubspec.yaml ├── lib │ └── main.dart └── .gitignore ├── pubspec.yaml ├── test ├── utils.dart ├── extension_test.dart ├── backoff_test.dart ├── throttle_test.dart └── debounce_test.dart ├── .github └── workflows │ └── main.yaml ├── LICENSE ├── .gitignore └── README.md /lib/rate_limiter.dart: -------------------------------------------------------------------------------- 1 | library rate_limiter; 2 | 3 | export 'src/backoff.dart'; 4 | export 'src/debounce.dart'; 5 | export 'src/throttle.dart'; 6 | export 'src/extension.dart'; 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.0.0] - (14-12-2022) 2 | 3 | * Added BackOff. 4 | * Added support for passing nullable values in Debounce and Throttle function. 5 | 6 | ## [0.1.1] - (13-04-2021) 7 | 8 | * Add example. 9 | 10 | ## [0.1.0] - (13-04-2021) 11 | 12 | * Initial release. -------------------------------------------------------------------------------- /example/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: example 2 | description: A new Flutter project. 3 | 4 | publish_to: "none" 5 | version: 0.0.1 6 | 7 | environment: 8 | sdk: ">=2.12.0 <3.0.0" 9 | 10 | dependencies: 11 | rate_limiter: 12 | path: ../ 13 | 14 | dependency_overrides: 15 | rate_limiter: 16 | path: ../ 17 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: rate_limiter 2 | description: A pure dart package to apply useful rate limiting strategies on regular functions. 3 | version: 1.0.0 4 | homepage: https://github.com/GetStream/rate_limiter 5 | repository: https://github.com/GetStream/rate_limiter 6 | issue_tracker: https://github.com/GetStream/rate_limiter/issues 7 | 8 | environment: 9 | sdk: ">=2.12.0 <3.0.0" 10 | 11 | dev_dependencies: 12 | test: ^1.16.8 -------------------------------------------------------------------------------- /test/utils.dart: -------------------------------------------------------------------------------- 1 | // Identity function 2 | T identity(T value) => value; 3 | 4 | // Extension function to convert int into durations 5 | extension IntX on int { 6 | Duration toDuration() => Duration(milliseconds: this); 7 | 8 | bool toBool() => this == 0 ? false : true; 9 | } 10 | 11 | // Top level util function to delay the code execution 12 | Future delay(int milliseconds) => 13 | Future.delayed(Duration(milliseconds: milliseconds)); 14 | -------------------------------------------------------------------------------- /test/extension_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:test/test.dart'; 2 | import 'package:rate_limiter/rate_limiter.dart'; 3 | 4 | void main() { 5 | test('should convert regular function into a debounce function', () { 6 | String regularFunction(String value) { 7 | return value; 8 | } 9 | 10 | final debounced = regularFunction.debounced(const Duration(seconds: 3)); 11 | expect(debounced, isA()); 12 | }); 13 | 14 | test('should convert regular function into a throttle function', () { 15 | String regularFunction(String value) { 16 | return value; 17 | } 18 | 19 | final throttled = regularFunction.throttled(const Duration(seconds: 3)); 20 | expect(throttled, isA()); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Dart CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: subosito/flutter-action@v1.4.0 11 | - name: Install Dependencies 12 | run: flutter pub get 13 | - name: Format 14 | run: flutter format --set-exit-if-changed . 15 | - name: Analyze 16 | run: flutter analyze 17 | - name: Run tests 18 | run: flutter test --no-pub --coverage 19 | - name: Check Code Coverage 20 | uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 21 | with: 22 | min_coverage: 97 23 | path: "./coverage/lcov.info" 24 | - name: Report CodeCov 25 | uses: codecov/codecov-action@v1.0.0 26 | with: 27 | token: ${{ secrets.CODECOV_TOKEN }} 28 | file: coverage/lcov.info -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 STREAM.IO, INC. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /example/lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:rate_limiter/rate_limiter.dart'; 2 | 3 | void main() { 4 | var count = 0; 5 | 6 | // updates the count value by one and prints it 7 | void regularFunction() { 8 | count += 1; 9 | print(count); 10 | } 11 | 12 | // regularFunction get's executed 10000 times 13 | for (var i = 0; i < 10000; i++) { 14 | regularFunction(); 15 | } 16 | 17 | // delays invoking `func` until after 100 milliseconds have elapsed 18 | // since the last time the debounced function was invoked 19 | final debouncedFunction = regularFunction.debounced( 20 | const Duration(milliseconds: 100), 21 | ); 22 | 23 | // debouncedFunction prints only once even though invoked 24 | // 1000000 times 25 | for (var i = 0; i < 10000; i++) { 26 | debouncedFunction(); 27 | } 28 | 29 | // only invokes `func` at most once per every 100 milliseconds 30 | final throttledFunction = regularFunction.throttled( 31 | const Duration(milliseconds: 100), 32 | ); 33 | 34 | // throttledFunction prints ~3 times even though invoked 35 | // 10000 times 36 | for (var i = 0; i < 10000; i++) { 37 | throttledFunction(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | coverage/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | **/doc/api/ 26 | .dart_tool/ 27 | .flutter-plugins 28 | .flutter-plugins-dependencies 29 | .packages 30 | .metadata 31 | .pub-cache/ 32 | .pub/ 33 | build/ 34 | pubspec.lock 35 | 36 | # Android related 37 | **/android/**/gradle-wrapper.jar 38 | **/android/.gradle 39 | **/android/captures/ 40 | **/android/gradlew 41 | **/android/gradlew.bat 42 | **/android/local.properties 43 | **/android/**/GeneratedPluginRegistrant.java 44 | 45 | # iOS/XCode related 46 | **/ios/**/*.mode1v3 47 | **/ios/**/*.mode2v3 48 | **/ios/**/*.moved-aside 49 | **/ios/**/*.pbxuser 50 | **/ios/**/*.perspectivev3 51 | **/ios/**/*sync/ 52 | **/ios/**/.sconsign.dblite 53 | **/ios/**/.tags* 54 | **/ios/**/.vagrant/ 55 | **/ios/**/DerivedData/ 56 | **/ios/**/Icon? 57 | **/ios/**/Pods/ 58 | **/ios/**/.symlinks/ 59 | **/ios/**/profile 60 | **/ios/**/xcuserdata 61 | **/ios/.generated/ 62 | **/ios/Flutter/App.framework 63 | **/ios/Flutter/Flutter.framework 64 | **/ios/Flutter/Flutter.podspec 65 | **/ios/Flutter/Generated.xcconfig 66 | **/ios/Flutter/app.flx 67 | **/ios/Flutter/app.zip 68 | **/ios/Flutter/flutter_assets/ 69 | **/ios/Flutter/flutter_export_environment.sh 70 | **/ios/ServiceDefinitions.json 71 | **/ios/Runner/GeneratedPluginRegistrant.* 72 | 73 | # Exceptions to above rules. 74 | !**/ios/**/default.mode1v3 75 | !**/ios/**/default.mode2v3 76 | !**/ios/**/default.pbxuser 77 | !**/ios/**/default.perspectivev3 78 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | coverage/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | **/doc/api/ 26 | .dart_tool/ 27 | .flutter-plugins 28 | .flutter-plugins-dependencies 29 | .packages 30 | .metadata 31 | .pub-cache/ 32 | .pub/ 33 | build/ 34 | pubspec.lock 35 | 36 | # Android related 37 | **/android/**/gradle-wrapper.jar 38 | **/android/.gradle 39 | **/android/captures/ 40 | **/android/gradlew 41 | **/android/gradlew.bat 42 | **/android/local.properties 43 | **/android/**/GeneratedPluginRegistrant.java 44 | 45 | # iOS/XCode related 46 | **/ios/**/*.mode1v3 47 | **/ios/**/*.mode2v3 48 | **/ios/**/*.moved-aside 49 | **/ios/**/*.pbxuser 50 | **/ios/**/*.perspectivev3 51 | **/ios/**/*sync/ 52 | **/ios/**/.sconsign.dblite 53 | **/ios/**/.tags* 54 | **/ios/**/.vagrant/ 55 | **/ios/**/DerivedData/ 56 | **/ios/**/Icon? 57 | **/ios/**/Pods/ 58 | **/ios/**/.symlinks/ 59 | **/ios/**/profile 60 | **/ios/**/xcuserdata 61 | **/ios/.generated/ 62 | **/ios/Flutter/App.framework 63 | **/ios/Flutter/Flutter.framework 64 | **/ios/Flutter/Flutter.podspec 65 | **/ios/Flutter/Generated.xcconfig 66 | **/ios/Flutter/app.flx 67 | **/ios/Flutter/app.zip 68 | **/ios/Flutter/flutter_assets/ 69 | **/ios/Flutter/flutter_export_environment.sh 70 | **/ios/ServiceDefinitions.json 71 | **/ios/Runner/GeneratedPluginRegistrant.* 72 | 73 | # Exceptions to above rules. 74 | !**/ios/**/default.mode1v3 75 | !**/ios/**/default.mode2v3 76 | !**/ios/**/default.pbxuser 77 | !**/ios/**/default.perspectivev3 78 | -------------------------------------------------------------------------------- /test/backoff_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:rate_limiter/rate_limiter.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | test('backOff (success)', () async { 6 | var count = 0; 7 | final f = backOff(() { 8 | count++; 9 | return true; 10 | }); 11 | expect(f, completion(isTrue)); 12 | expect(count, equals(1)); 13 | }); 14 | 15 | test('retry (unhandled exception)', () async { 16 | var count = 0; 17 | final f = backOff( 18 | () { 19 | count++; 20 | throw Exception('Retry will fail'); 21 | }, 22 | maxAttempts: 5, 23 | retryIf: (_, __) => false, 24 | ); 25 | await expectLater(f, throwsA(isException)); 26 | expect(count, equals(1)); 27 | }); 28 | 29 | test('retry (retryIf, exhaust retries)', () async { 30 | var count = 0; 31 | final f = backOff( 32 | () { 33 | count++; 34 | throw FormatException('Retry will fail'); 35 | }, 36 | maxAttempts: 5, 37 | maxDelay: Duration(), 38 | retryIf: (e, _) => e is FormatException, 39 | ); 40 | await expectLater(f, throwsA(isFormatException)); 41 | expect(count, equals(5)); 42 | }); 43 | 44 | test('retry (success after 2)', () async { 45 | var count = 0; 46 | final f = backOff( 47 | () { 48 | count++; 49 | if (count == 1) { 50 | throw FormatException('Retry will be okay'); 51 | } 52 | return true; 53 | }, 54 | maxAttempts: 5, 55 | maxDelay: Duration(), 56 | retryIf: (e, _) => e is FormatException, 57 | ); 58 | await expectLater(f, completion(isTrue)); 59 | expect(count, equals(2)); 60 | }); 61 | 62 | test('retry (no retryIf)', () async { 63 | var count = 0; 64 | final f = backOff( 65 | () { 66 | count++; 67 | if (count == 1) { 68 | throw FormatException('Retry will be okay'); 69 | } 70 | return true; 71 | }, 72 | maxAttempts: 5, 73 | maxDelay: Duration(), 74 | ); 75 | await expectLater(f, completion(isTrue)); 76 | expect(count, equals(2)); 77 | }); 78 | 79 | test('retry (unhandled on 2nd try)', () async { 80 | var count = 0; 81 | final f = backOff( 82 | () { 83 | count++; 84 | if (count == 1) { 85 | throw FormatException('Retry will be okay'); 86 | } 87 | throw Exception('unhandled thing'); 88 | }, 89 | maxAttempts: 5, 90 | maxDelay: Duration(), 91 | retryIf: (e, _) => e is FormatException, 92 | ); 93 | await expectLater(f, throwsA(isException)); 94 | expect(count, equals(2)); 95 | }); 96 | } 97 | -------------------------------------------------------------------------------- /lib/src/extension.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'backoff.dart'; 4 | import 'debounce.dart'; 5 | import 'throttle.dart'; 6 | 7 | /// Useful rate limiter extensions for [Function] class. 8 | extension BackOffExtension on FutureOr Function() { 9 | /// Converts this into a [BackOff] function. 10 | Future backOff({ 11 | Duration delayFactor = const Duration(milliseconds: 200), 12 | double randomizationFactor = 0.25, 13 | Duration maxDelay = const Duration(seconds: 30), 14 | int maxAttempts = 8, 15 | FutureOr Function(Object error, int attempt)? retry, 16 | }) => 17 | BackOff( 18 | this, 19 | delayFactor: delayFactor, 20 | randomizationFactor: randomizationFactor, 21 | maxDelay: maxDelay, 22 | maxAttempts: maxAttempts, 23 | retryIf: retry, 24 | ).call(); 25 | } 26 | 27 | /// Useful rate limiter extensions for [Function] class. 28 | extension RateLimit on Function { 29 | /// Converts this into a [Debounce] function. 30 | Debounce debounced( 31 | Duration wait, { 32 | bool leading = false, 33 | bool trailing = true, 34 | Duration? maxWait, 35 | }) => 36 | Debounce( 37 | this, 38 | wait, 39 | leading: leading, 40 | trailing: trailing, 41 | maxWait: maxWait, 42 | ); 43 | 44 | /// Converts this into a [Throttle] function. 45 | Throttle throttled( 46 | Duration wait, { 47 | bool leading = true, 48 | bool trailing = true, 49 | }) => 50 | Throttle( 51 | this, 52 | wait, 53 | leading: leading, 54 | trailing: trailing, 55 | ); 56 | } 57 | 58 | /// TopLevel lambda to apply [BackOff] to functions. 59 | Future backOff( 60 | FutureOr Function() func, { 61 | Duration delayFactor = const Duration(milliseconds: 200), 62 | double randomizationFactor = 0.25, 63 | Duration maxDelay = const Duration(seconds: 30), 64 | int maxAttempts = 8, 65 | FutureOr Function(Object error, int attempt)? retryIf, 66 | }) => 67 | BackOff( 68 | func, 69 | delayFactor: delayFactor, 70 | randomizationFactor: randomizationFactor, 71 | maxDelay: maxDelay, 72 | maxAttempts: maxAttempts, 73 | retryIf: retryIf, 74 | ).call(); 75 | 76 | /// TopLevel lambda to create [Debounce] functions. 77 | Debounce debounce( 78 | Function func, 79 | Duration wait, { 80 | bool leading = false, 81 | bool trailing = true, 82 | Duration? maxWait, 83 | }) => 84 | Debounce( 85 | func, 86 | wait, 87 | leading: leading, 88 | trailing: trailing, 89 | maxWait: maxWait, 90 | ); 91 | 92 | /// TopLevel lambda to create [Throttle] functions. 93 | Throttle throttle( 94 | Function func, 95 | Duration wait, { 96 | bool leading = true, 97 | bool trailing = true, 98 | }) => 99 | Throttle( 100 | func, 101 | wait, 102 | leading: leading, 103 | trailing: trailing, 104 | ); 105 | -------------------------------------------------------------------------------- /lib/src/backoff.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:math' as math; 3 | 4 | final _rand = math.Random(); 5 | 6 | /// Object holding options for retrying a function. 7 | /// 8 | /// With the default configuration functions will be retried up-to 7 times 9 | /// (8 attempts in total), sleeping 1st, 2nd, 3rd, ..., 7th attempt: 10 | /// 1. 400 ms +/- 25% 11 | /// 2. 800 ms +/- 25% 12 | /// 3. 1600 ms +/- 25% 13 | /// 4. 3200 ms +/- 25% 14 | /// 5. 6400 ms +/- 25% 15 | /// 6. 12800 ms +/- 25% 16 | /// 7. 25600 ms +/- 25% 17 | /// 18 | /// **Example** 19 | /// ```dart 20 | /// final response = await backOff( 21 | /// // Make a GET request 22 | /// () => http.get('https://google.com').timeout(Duration(seconds: 5)), 23 | /// // Retry on SocketException or TimeoutException 24 | /// retryIf: (e) => e is SocketException || e is TimeoutException, 25 | /// ); 26 | /// print(response.body); 27 | /// ``` 28 | class BackOff { 29 | const BackOff( 30 | this.func, { 31 | this.delayFactor = const Duration(milliseconds: 200), 32 | this.randomizationFactor = 0.25, 33 | this.maxDelay = const Duration(seconds: 30), 34 | this.maxAttempts = 8, 35 | this.retryIf, 36 | }) : assert(maxAttempts >= 1, 'maxAttempts must be greater than 0'); 37 | 38 | /// The [Function] to execute. If the function throws an error, it will be 39 | /// retried [maxAttempts] times with an increasing delay between each attempt 40 | /// up to [maxDelay]. 41 | /// 42 | /// If [retryIf] is provided, the function will only be retried if the error 43 | /// matches the predicate. 44 | final FutureOr Function() func; 45 | 46 | /// Delay factor to double after every attempt. 47 | /// 48 | /// Defaults to 200 ms, which results in the following delays: 49 | /// 50 | /// 1. 400 ms 51 | /// 2. 800 ms 52 | /// 3. 1600 ms 53 | /// 4. 3200 ms 54 | /// 5. 6400 ms 55 | /// 6. 12800 ms 56 | /// 7. 25600 ms 57 | /// 58 | /// Before application of [randomizationFactor]. 59 | final Duration delayFactor; 60 | 61 | /// Percentage the delay should be randomized, given as fraction between 62 | /// 0 and 1. 63 | /// 64 | /// If [randomizationFactor] is `0.25` (default) this indicates 25 % of the 65 | /// delay should be increased or decreased by 25 %. 66 | final double randomizationFactor; 67 | 68 | /// Maximum delay between retries, defaults to 30 seconds. 69 | final Duration maxDelay; 70 | 71 | /// Maximum number of attempts before giving up, defaults to 8. 72 | final int maxAttempts; 73 | 74 | /// Function to determine if a retry should be attempted. 75 | /// 76 | /// If `null` (default) all errors will be retried. 77 | final FutureOr Function(Object error, int attempt)? retryIf; 78 | 79 | // returns the sleep duration based on `attempt`. 80 | Duration _getSleepDuration(int attempt) { 81 | final rf = (randomizationFactor * (_rand.nextDouble() * 2 - 1) + 1); 82 | final exp = math.min(attempt, 31); // prevent overflows. 83 | final delay = (delayFactor * math.pow(2.0, exp) * rf); 84 | return delay < maxDelay ? delay : maxDelay; 85 | } 86 | 87 | Future call() async { 88 | var attempt = 0; 89 | while (true) { 90 | attempt++; // first invocation is the first attempt. 91 | try { 92 | return await func(); 93 | } catch (error) { 94 | final attemptLimitReached = attempt >= maxAttempts; 95 | if (attemptLimitReached) rethrow; 96 | 97 | final shouldRetry = await retryIf?.call(error, attempt); 98 | if (shouldRetry == false) rethrow; 99 | } 100 | 101 | // sleep for a delay. 102 | await Future.delayed(_getSleepDuration(attempt)); 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /lib/src/throttle.dart: -------------------------------------------------------------------------------- 1 | import 'debounce.dart'; 2 | 3 | /// Creates a throttled function that only invokes `func` at most once per 4 | /// every `wait` milliseconds. The throttled function comes with a [Throttle.cancel] 5 | /// method to cancel delayed `func` invocations and a [Throttle.flush] method to 6 | /// immediately invoke them. Provide `leading` and/or `trailing` to indicate 7 | /// whether `func` should be invoked on the `leading` and/or `trailing` edge of the `wait` timeout. 8 | /// The `func` is invoked with the last arguments provided to the 9 | /// throttled function. Subsequent calls to the throttled function return the 10 | /// result of the last `func` invocation. 11 | /// 12 | /// **Note:** If `leading` and `trailing` options are `true`, `func` is 13 | /// invoked on the trailing edge of the timeout only if the throttled function 14 | /// is invoked more than once during the `wait` timeout. 15 | /// 16 | /// If `wait` is [Duration.zero] and `leading` is `false`, `func` invocation is deferred 17 | /// until the next tick. 18 | /// 19 | /// See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) 20 | /// for details over the differences between [Throttle] and [Debounce]. 21 | /// 22 | /// Some examples: 23 | /// 24 | /// Avoid excessively rebuilding UI progress while uploading data to server. 25 | /// ```dart 26 | /// void updateUI(Data data) { 27 | /// updateProgress(data); 28 | /// } 29 | /// 30 | /// final throttledUpdateUI = Throttle( 31 | /// updateUI, 32 | /// const Duration(milliseconds: 350), 33 | /// ); 34 | /// 35 | /// void onUploadProgressChanged(progress) { 36 | /// throttledUpdateUI(progress); 37 | /// } 38 | /// ``` 39 | /// 40 | /// Cancel the trailing throttled invocation. 41 | /// ```dart 42 | /// void dispose() { 43 | /// throttled.cancel(); 44 | /// } 45 | /// ``` 46 | /// 47 | /// Check for pending invocations. 48 | /// ```dart 49 | /// final status = throttled.isPending ? "Pending..." : "Ready"; 50 | /// ``` 51 | class Throttle { 52 | /// Creates a new instance of [Throttle] 53 | Throttle( 54 | Function func, 55 | Duration wait, { 56 | bool leading = true, 57 | bool trailing = true, 58 | }) : _debounce = Debounce( 59 | func, 60 | wait, 61 | leading: leading, 62 | trailing: trailing, 63 | maxWait: wait, 64 | ); 65 | 66 | final Debounce _debounce; 67 | 68 | /// Cancels all the remaining delayed functions. 69 | void cancel() => _debounce.cancel(); 70 | 71 | /// Immediately invokes all the remaining delayed functions. 72 | Object? flush() => _debounce.flush(); 73 | 74 | /// True if there are functions remaining to get invoked. 75 | bool get isPending => _debounce.isPending; 76 | 77 | /// Dynamically call this [Throttle] with the specified arguments. 78 | /// 79 | /// Acts the same as calling [func] with positional arguments 80 | /// corresponding to the elements of [args] and 81 | /// named arguments corresponding to the elements of [namedArgs]. 82 | /// 83 | /// This includes giving the same errors if [func] isn't callable or 84 | /// if it expects different parameters. 85 | /// 86 | /// Example: 87 | /// ```dart 88 | /// List fetchMovies( 89 | /// String movieName, { 90 | /// bool adult = false, 91 | /// }) async { 92 | /// final data = api.getData(query); 93 | /// doSomethingWithTheData(data); 94 | /// } 95 | /// 96 | /// final throttledFetchMovies = Throttle( 97 | /// fetchMovies, 98 | /// const Duration(milliseconds: 350), 99 | /// ); 100 | /// 101 | /// throttledFetchMovies(['tenet'], {#adult: true}); 102 | /// ``` 103 | /// 104 | /// gives exactly the same result as 105 | /// ``` 106 | /// fetchMovies('tenet', adult: true). 107 | /// ``` 108 | Object? call([List? args, Map? namedArgs]) => 109 | _debounce.call(args, namedArgs); 110 | } 111 | -------------------------------------------------------------------------------- /test/throttle_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:rate_limiter/rate_limiter.dart'; 2 | import 'package:test/test.dart'; 3 | import 'utils.dart'; 4 | 5 | void main() { 6 | group('throttle', () { 7 | test('should throttle a function', () async { 8 | var callCount = 0; 9 | final throttled = throttle(() { 10 | callCount++; 11 | }, 32.toDuration()); 12 | 13 | throttled(); 14 | throttled(); 15 | throttled(); 16 | 17 | var lastCount = callCount; 18 | expect(callCount.toBool(), isTrue); 19 | 20 | await delay(64); 21 | expect(callCount > lastCount, isTrue); 22 | }); 23 | 24 | test('should cancel all the remaining delayed functions', () async { 25 | var callCount = 0; 26 | 27 | final throttled = throttle((String value) { 28 | ++callCount; 29 | return value; 30 | }, const Duration(milliseconds: 32), leading: false); 31 | 32 | var results = [ 33 | throttled(['a']), 34 | throttled(['b']), 35 | throttled(['c']) 36 | ]; 37 | 38 | await delay(30); 39 | 40 | throttled.cancel(); 41 | 42 | expect(results, [null, null, null]); 43 | expect(callCount, 0); 44 | }); 45 | 46 | test( 47 | 'should immediately invokes all the remaining delayed functions', 48 | () async { 49 | var callCount = 0; 50 | 51 | final throttled = throttle((String value) { 52 | ++callCount; 53 | return value; 54 | }, const Duration(milliseconds: 32), leading: false); 55 | 56 | throttled(['a']); 57 | throttled(['b']); 58 | throttled(['c']); 59 | 60 | final result = throttled.flush(); 61 | 62 | expect(result, 'c'); 63 | expect(callCount, 1); 64 | }, 65 | ); 66 | 67 | test( 68 | 'should return if there are functions remaining to get invoked', 69 | () async { 70 | final throttled = throttle(identity, const Duration(milliseconds: 32)); 71 | 72 | throttled(['a']); 73 | 74 | expect(throttled.isPending, true); 75 | 76 | await delay(32); 77 | 78 | expect(throttled.isPending, false); 79 | }, 80 | ); 81 | 82 | test( 83 | 'subsequent calls should return the result of the first call', 84 | () async { 85 | final throttled = throttle(identity, 32.toDuration()); 86 | var results = [ 87 | throttled(['a']), 88 | throttled(['b']) 89 | ]; 90 | 91 | expect(results, ['a', 'a']); 92 | 93 | await delay(64); 94 | results = [ 95 | throttled(['c']), 96 | throttled(['d']) 97 | ]; 98 | 99 | expect(results[0], isNot('a')); 100 | expect(results[0], isNotNull); 101 | 102 | expect(results[1], isNot('d')); 103 | expect(results[1], isNotNull); 104 | }, 105 | ); 106 | 107 | test('should not trigger a trailing call when invoked once', () async { 108 | var callCount = 0; 109 | final throttled = throttle(() { 110 | callCount++; 111 | }, 32.toDuration()); 112 | 113 | throttled(); 114 | expect(callCount, 1); 115 | 116 | await delay(64); 117 | expect(callCount, 1); 118 | }); 119 | 120 | test('should trigger a call when invoked repeatedly', () async { 121 | var callCount = 0; 122 | var limit = 320; 123 | final throttled = throttle(() { 124 | callCount++; 125 | }, 32.toDuration()); 126 | 127 | var start = DateTime.now().millisecondsSinceEpoch; 128 | while ((DateTime.now().millisecondsSinceEpoch - start) < limit) { 129 | throttled(); 130 | } 131 | var actual = callCount > 1; 132 | 133 | await delay(1); 134 | expect(actual, isTrue); 135 | }); 136 | 137 | test( 138 | 'should trigger a call when invoked repeatedly and `leading` is `false`', 139 | () async { 140 | var callCount = 0; 141 | var limit = 320; 142 | final throttled = throttle(() { 143 | callCount++; 144 | }, 32.toDuration(), leading: false); 145 | 146 | var start = DateTime.now().millisecondsSinceEpoch; 147 | while ((DateTime.now().millisecondsSinceEpoch - start) < limit) { 148 | throttled(); 149 | } 150 | var actual = callCount > 1; 151 | 152 | await delay(1); 153 | expect(actual, isTrue); 154 | }, 155 | ); 156 | 157 | test('should apply default options', () async { 158 | var callCount = 0; 159 | final throttled = throttle(() { 160 | callCount++; 161 | }, 32.toDuration()); 162 | 163 | throttled(); 164 | throttled(); 165 | expect(callCount, 1); 166 | 167 | await delay(128); 168 | expect(callCount, 2); 169 | }); 170 | 171 | test('should support a `leading` option', () { 172 | final withLeading = throttle( 173 | identity, 174 | 32.toDuration(), 175 | leading: true, 176 | ); 177 | expect(withLeading(['a']), 'a'); 178 | 179 | final withoutLeading = throttle( 180 | identity, 181 | 32.toDuration(), 182 | leading: false, 183 | ); 184 | expect(withoutLeading(['a']), isNull); 185 | }); 186 | 187 | test('should support a `trailing` option', () async { 188 | var withCount = 0; 189 | var withoutCount = 0; 190 | 191 | final withTrailing = throttle((value) { 192 | withCount++; 193 | return value; 194 | }, 64.toDuration(), trailing: true); 195 | 196 | final withoutTrailing = throttle((value) { 197 | withoutCount++; 198 | return value; 199 | }, 64.toDuration(), trailing: false); 200 | 201 | expect(withTrailing(['a']), 'a'); 202 | expect(withTrailing(['b']), 'a'); 203 | 204 | expect(withoutTrailing(['a']), 'a'); 205 | expect(withoutTrailing(['b']), 'a'); 206 | 207 | await delay(256); 208 | expect(withCount, 2); 209 | expect(withoutCount, 1); 210 | }); 211 | 212 | test( 213 | 'should not update `lastCalled`, at the end of the timeout, when `trailing` is `false`', 214 | () async { 215 | var callCount = 0; 216 | 217 | final throttled = throttle(() { 218 | callCount++; 219 | }, 64.toDuration(), trailing: false); 220 | 221 | throttled(); 222 | throttled(); 223 | 224 | await delay(96); 225 | throttled(); 226 | throttled(); 227 | 228 | await delay(192); 229 | expect(callCount > 1, isTrue); 230 | }, 231 | ); 232 | }); 233 | } 234 | -------------------------------------------------------------------------------- /lib/src/debounce.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async' show Timer; 2 | import 'dart:math' as math; 3 | 4 | const _undefined = Object(); 5 | 6 | /// Creates a debounced function that delays invoking `func` until after `wait` 7 | /// milliseconds have elapsed since the last time the debounced function was 8 | /// invoked. The debounced function comes with a [Debounce.cancel] method to cancel 9 | /// delayed `func` invocations and a [Debounce.flush] method to immediately invoke them. 10 | /// Provide `leading` and/or `trailing` to indicate whether `func` should be 11 | /// invoked on the `leading` and/or `trailing` edge of the `wait` interval. 12 | /// The `func` is invoked with the last arguments provided to the [call] 13 | /// function. Subsequent calls to the debounced function return the result of 14 | /// the last `func` invocation. 15 | /// 16 | /// **Note:** If `leading` and `trailing` options are `true`, `func` is 17 | /// invoked on the trailing edge of the timeout only if the debounced function 18 | /// is invoked more than once during the `wait` timeout. 19 | /// 20 | /// If `wait` is [Duration.zero] and `leading` is `false`, 21 | /// `func` invocation is deferred until the next tick. 22 | /// 23 | /// See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) 24 | /// for details over the differences between [Debounce] and [Throttle]. 25 | /// 26 | /// Some examples: 27 | /// 28 | /// Avoid calling costly network calls when user is typing something. 29 | /// ```dart 30 | /// void fetchData(String query) async { 31 | /// final data = api.getData(query); 32 | /// doSomethingWithTheData(data); 33 | /// } 34 | /// 35 | /// final debouncedFetchData = Debounce( 36 | /// fetchData, 37 | /// const Duration(milliseconds: 350), 38 | /// ); 39 | /// 40 | /// void onSearchQueryChanged(query) { 41 | /// debouncedFetchData([query]); 42 | /// } 43 | /// ``` 44 | /// 45 | /// Cancel the trailing debounced invocation. 46 | /// ```dart 47 | /// void dispose() { 48 | /// debounced.cancel(); 49 | /// } 50 | /// ``` 51 | /// 52 | /// Check for pending invocations. 53 | /// ```dart 54 | /// final status = debounced.isPending ? "Pending..." : "Ready"; 55 | /// ``` 56 | class Debounce { 57 | /// Creates a new instance of [Debounce]. 58 | Debounce( 59 | this._func, 60 | Duration wait, { 61 | bool leading = false, 62 | bool trailing = true, 63 | Duration? maxWait, 64 | }) : _leading = leading, 65 | _trailing = trailing, 66 | _wait = wait.inMilliseconds, 67 | _maxing = maxWait != null { 68 | if (_maxing) { 69 | _maxWait = math.max(maxWait!.inMilliseconds, _wait); 70 | } 71 | } 72 | 73 | final Function _func; 74 | final bool _leading; 75 | final bool _trailing; 76 | final int _wait; 77 | final bool _maxing; 78 | 79 | int? _maxWait; 80 | Object? _lastArgs = _undefined; 81 | Object? _lastNamedArgs = _undefined; 82 | Timer? _timer; 83 | int? _lastCallTime; 84 | Object? _result; 85 | int _lastInvokeTime = 0; 86 | 87 | Object? _invokeFunc(int time) { 88 | final args = _lastArgs as List?; 89 | final namedArgs = _lastNamedArgs as Map?; 90 | _lastInvokeTime = time; 91 | _lastArgs = _lastNamedArgs = _undefined; 92 | return _result = Function.apply(_func, args, namedArgs); 93 | } 94 | 95 | Timer _startTimer(Function pendingFunc, int wait) => 96 | Timer(Duration(milliseconds: wait), () => pendingFunc()); 97 | 98 | bool _shouldInvoke(int time) { 99 | // This is our first call. 100 | if (_lastCallTime == null) return true; 101 | 102 | final timeSinceLastCall = time - _lastCallTime!; 103 | final timeSinceLastInvoke = time - _lastInvokeTime; 104 | 105 | // Either activity has stopped and we're at the 106 | // trailing edge, the system time has gone backwards and we're treating 107 | // it as the trailing edge, or we've hit the `maxWait` limit. 108 | return (timeSinceLastCall >= _wait) || 109 | (timeSinceLastCall < 0) || 110 | (_maxing && timeSinceLastInvoke >= _maxWait!); 111 | } 112 | 113 | Object? _trailingEdge(int time) { 114 | _timer = null; 115 | 116 | // Only invoke if we have `_lastArgs` or `_lastNamedArgs` which means 117 | // `func` has been debounced at least once. 118 | if (_trailing && 119 | (_lastArgs != _undefined || _lastNamedArgs != _undefined)) { 120 | return _invokeFunc(time); 121 | } 122 | _lastArgs = _lastNamedArgs = _undefined; 123 | return _result; 124 | } 125 | 126 | int _remainingWait(int time) { 127 | final timeSinceLastCall = time - _lastCallTime!; 128 | final timeSinceLastInvoke = time - _lastInvokeTime; 129 | final timeWaiting = _wait - timeSinceLastCall; 130 | 131 | return _maxing 132 | ? math.min(timeWaiting, _maxWait! - timeSinceLastInvoke) 133 | : timeWaiting; 134 | } 135 | 136 | void _timerExpired() { 137 | final time = DateTime.now().millisecondsSinceEpoch; 138 | if (_shouldInvoke(time)) { 139 | _trailingEdge(time); 140 | } else { 141 | // Restart the timer. 142 | _timer = _startTimer(_timerExpired, _remainingWait(time)); 143 | } 144 | } 145 | 146 | Object? _leadingEdge(int time) { 147 | // Reset any `maxWait` timer. 148 | _lastInvokeTime = time; 149 | // Start the timer for the trailing edge. 150 | _timer = _startTimer(_timerExpired, _wait); 151 | // Invoke the leading edge. 152 | return _leading ? _invokeFunc(time) : _result; 153 | } 154 | 155 | /// Cancels all the remaining delayed functions. 156 | void cancel() { 157 | _timer?.cancel(); 158 | _lastInvokeTime = 0; 159 | _lastCallTime = _timer = null; 160 | _lastArgs = _lastNamedArgs = _undefined; 161 | } 162 | 163 | /// Immediately invokes all the remaining delayed functions. 164 | Object? flush() { 165 | final now = DateTime.now().millisecondsSinceEpoch; 166 | return _timer == null ? _result : _trailingEdge(now); 167 | } 168 | 169 | /// True if there are functions remaining to get invoked. 170 | bool get isPending => _timer != null; 171 | 172 | /// Dynamically call this [Debounce] with the specified arguments. 173 | /// 174 | /// Acts the same as calling [_func] with positional arguments 175 | /// corresponding to the elements of [args] and 176 | /// named arguments corresponding to the elements of [namedArgs]. 177 | /// 178 | /// This includes giving the same errors if [_func] isn't callable or 179 | /// if it expects different parameters. 180 | /// 181 | /// Example: 182 | /// ```dart 183 | /// List fetchMovies( 184 | /// String movieName, { 185 | /// bool adult = false, 186 | /// }) async { 187 | /// final data = api.getData(query); 188 | /// doSomethingWithTheData(data); 189 | /// } 190 | /// 191 | /// final debouncedFetchMovies = Debounce( 192 | /// fetchMovies, 193 | /// const Duration(milliseconds: 350), 194 | /// ); 195 | /// 196 | /// debouncedFetchMovies(['tenet'], {#adult: true}); 197 | /// ``` 198 | /// 199 | /// gives exactly the same result as 200 | /// ``` 201 | /// fetchMovies('tenet', adult: true). 202 | /// ``` 203 | Object? call([List? args, Map? namedArgs]) { 204 | final time = DateTime.now().millisecondsSinceEpoch; 205 | final isInvoking = _shouldInvoke(time); 206 | 207 | _lastArgs = args; 208 | _lastNamedArgs = namedArgs; 209 | _lastCallTime = time; 210 | 211 | if (isInvoking) { 212 | if (_timer == null) { 213 | return _leadingEdge(_lastCallTime!); 214 | } 215 | if (_maxing) { 216 | // Handle invocations in a tight loop. 217 | _timer = _startTimer(_timerExpired, _wait); 218 | return _invokeFunc(_lastCallTime!); 219 | } 220 | } 221 | _timer ??= _startTimer(_timerExpired, _wait); 222 | return _result; 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Rate Limiter 3 | 4 | 5 | 6 | [![Open Source Love](https://badges.frapsoft.com/os/v1/open-source.svg?v=102)](https://opensource.org/licenses/MIT) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/GetStream/rate_limit/blob/master/LICENSE) [![Dart CI](https://github.com/GetStream/rate_limiter/workflows/Dart%20CI/badge.svg)](https://github.com/GetStream/rate_limiter/actions) [![CodeCov](https://codecov.io/gh/GetStream/rate_limiter/branch/master/graph/badge.svg)](https://codecov.io/gh/GetStream/rate_limiter) [![Version](https://img.shields.io/pub/v/rate_limiter.svg)](https://pub.dartlang.org/packages/rate_limiter) 7 | 8 | **[** Built with ♥ at [Stream](https://getstream.io/) **]** 9 | 10 | ## Introduction 11 | _Rate limiting_ is a strategy for limiting an action. It puts a cap on how often someone can repeat an action within a certain timeframe. Using `rate_limiter` we made it easier than ever to apply these strategies on regular dart functions. 12 | 13 | ( Inspired from [lodash](https://lodash.com/) ) 14 | 15 | ## Index 16 | - [Installation](#installation) 17 | - [Strategies](#strategies) 18 | - [Debounce](#debounce) 19 | - [Throttle](#throttle) 20 | - [BackOff](#backoff) 21 | - [Pending](#pending) 22 | - [Flush](#flush) 23 | - [Cancellation](#cancellation) 24 | 25 | ## Installation 26 | Add the following to your `pubspec.yaml` and replace `[version]` with the latest version: 27 | ```yaml 28 | dependencies: 29 | rate_limiter: ^[version] 30 | ``` 31 | 32 | ## Strategies 33 | ### Debounce 34 | A _debounced function_ will ignore all calls to it until the calls have stopped for a specified time period. Only then it will call the original function. For instance, if we specify the time as two seconds, and the debounced function is called 10 times with an interval of one second between each call, the function will not call the original function until two seconds after the last (tenth) call. 35 | 36 | #### Usage 37 | It's fairly simple to create debounced function with `rate_limiter` 38 | 39 | 1. Creating from scratch 40 | ```dart 41 | final debouncedFunction = debounce((String value) { 42 | print('Got value : $value'); 43 | return value; 44 | }, const Duration(seconds: 2)); 45 | ``` 46 | 2. Converting an existing function into debounced function 47 | ```dart 48 | String regularFunction(String value) { 49 | print('Got value : $value'); 50 | return value; 51 | } 52 | 53 | final debouncedFunction = regularFunction.debounced( 54 | const Duration(seconds: 2), 55 | ); 56 | ``` 57 | 58 | #### Example 59 | Often times, search boxes offer dropdowns that provide autocomplete options for the user’s current input. Sometimes the items suggested are fetched from the backend via API (for instance, on Google Maps). The autocomplete API gets called whenever the search query changes. Without debounce, an API call would be made for every letter you type, even if you’re typing very fast. Debouncing by one second will ensure that the autocomplete function does nothing until one second after the user is done typing. 60 | ```dart 61 | final debouncedAutocompleteSearch = debounce( 62 | (String searchQuery) async { 63 | // fetches results from the api 64 | final results = await searchApi.get(searchQuery); 65 | // updates suggestion list 66 | updateSearchSuggestions(results); 67 | }, 68 | const Duration(seconds: 1), 69 | ); 70 | 71 | TextField( 72 | onChanged: (query) { 73 | debouncedAutocompleteSearch([query]); 74 | }, 75 | ); 76 | ``` 77 | 78 | ### Throttle 79 | To _throttle_ a function means to ensure that the function is called at most once in a specified time period (for instance, once every 10 seconds). This means throttling will prevent a function from running if it has run “recently”. Throttling also ensures a function is run regularly at a fixed rate. 80 | 81 | #### Usage 82 | Creating throttled function is similar to debounce function 83 | 84 | 1. Creating from scratch 85 | ```dart 86 | final throttledFunction = throttle((String value) { 87 | print('Got value : $value'); 88 | return value; 89 | }, const Duration(seconds: 2)); 90 | ``` 91 | 2. Converting an existing function into throttled function 92 | ```dart 93 | String regularFunction(String value) { 94 | print('Got value : $value'); 95 | return value; 96 | } 97 | 98 | final throttledFunction = regularFunction.throttled( 99 | const Duration(seconds: 2), 100 | ); 101 | ``` 102 | 103 | #### Example 104 | In action games, the user often performs a key action by pushing a button (example: shooting, punching). But, as any gamer knows, users often press the buttons much more than is necessary, probably due to the excitement and intensity of the action. So the user might hit “Punch” 10 times in 5 seconds, but the game character can only throw one punch in one second. In such a situation, it makes sense to throttle the action. In this case, throttling the “Punch” action to one second would ignore the second button press each second. 105 | 106 | ```dart 107 | final throttledPerformPunch = throttle( 108 | () { 109 | print('Performed one punch to the opponent'); 110 | }, 111 | const Duration(seconds: 1), 112 | ); 113 | 114 | RaisedButton( 115 | onPressed: (){ 116 | throttledPerformPunch(); 117 | } 118 | child: Text('Punch') 119 | ); 120 | ``` 121 | 122 | ### BackOff 123 | BackOff is a strategy that allows you to retry a function call multiple times with a delay between each call. It is useful when you want to retry a function call multiple times in case of failure. 124 | 125 | #### Usage 126 | Creating backoff function is similar to debounce and throttle function. 127 | 128 | 1. Creating from scratch 129 | ```dart 130 | final response = backOff( 131 | // Make a GET request 132 | () => http.get('https://google.com').timeout(Duration(seconds: 5)), 133 | maxAttempts: 5, 134 | maxDelay: Duration(seconds: 5), 135 | // Retry on SocketException or TimeoutException 136 | retryIf: (e, _) => e is SocketException || e is TimeoutException, 137 | ); 138 | ``` 139 | 2. Converting an existing function into backoff function 140 | ```dart 141 | Future regularFunction() async { 142 | // Make a GET request 143 | final response = await http.get('https://google.com').timeout(Duration(seconds: 5)); 144 | return response.body; 145 | } 146 | 147 | final response = regularFunction.backOff( 148 | maxAttempts: 5, 149 | maxDelay: Duration(seconds: 5), 150 | // Retry on SocketException or TimeoutException 151 | retryIf: (e, _) => e is SocketException || e is TimeoutException, 152 | ); 153 | ``` 154 | 155 | #### Example 156 | While making a network request, it is possible that the request fails due to network issues. In such cases, it is useful to retry the request multiple times with a delay between each call. This is where backoff strategy comes in handy. 157 | 158 | ```dart 159 | final response = backOff( 160 | // Make a GET request 161 | () => http.get('https://google.com').timeout(Duration(seconds: 5)), 162 | maxAttempts: 5, 163 | maxDelay: Duration(seconds: 5), 164 | // Retry on SocketException or TimeoutException 165 | retryIf: (e, _) => e is SocketException || e is TimeoutException, 166 | ); 167 | ``` 168 | 169 | ### Pending 170 | Used to check if the there are functions still remaining to get invoked. 171 | ```dart 172 | final pending = debouncedFunction.isPending; 173 | final pending = throttledFunction.isPending; 174 | ``` 175 | 176 | ### Flush 177 | Used to immediately invoke all the remaining delayed functions. 178 | ```dart 179 | final result = debouncedFunction.flush(); 180 | final result = throttledFunction.flush(); 181 | ``` 182 | 183 | ### Cancellation 184 | Used to cancel all the remaining delayed functions. 185 | ```dart 186 | debouncedFunction.cancel(); 187 | throttledFunction.cancel(); 188 | ``` 189 | -------------------------------------------------------------------------------- /test/debounce_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:rate_limiter/rate_limiter.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import 'utils.dart'; 5 | 6 | void main() { 7 | group('debounce', () { 8 | test('should debounce a function', () async { 9 | var callCount = 0; 10 | 11 | final debounced = debounce((String value) { 12 | ++callCount; 13 | return value; 14 | }, const Duration(milliseconds: 32)); 15 | 16 | var results = [ 17 | debounced(['a']), 18 | debounced(['b']), 19 | debounced(['c']) 20 | ]; 21 | 22 | expect(results, [null, null, null]); 23 | expect(callCount, 0); 24 | 25 | await delay(128); 26 | 27 | results = [ 28 | debounced(['d']), 29 | debounced(['e']), 30 | debounced(['f']) 31 | ]; 32 | 33 | expect(results, ['c', 'c', 'c']); 34 | expect(callCount, 1); 35 | 36 | await delay(256); 37 | 38 | expect(callCount, 2); 39 | }); 40 | 41 | test('should cancel all the remaining delayed functions', () async { 42 | var callCount = 0; 43 | 44 | final debounced = debounce((String value) { 45 | ++callCount; 46 | return value; 47 | }, const Duration(milliseconds: 32)); 48 | 49 | var results = [ 50 | debounced(['a']), 51 | debounced(['b']), 52 | debounced(['c']) 53 | ]; 54 | 55 | await delay(30); 56 | 57 | debounced.cancel(); 58 | 59 | expect(results, [null, null, null]); 60 | expect(callCount, 0); 61 | }); 62 | 63 | test( 64 | 'should immediately invokes all the remaining delayed functions', 65 | () async { 66 | var callCount = 0; 67 | 68 | final debounced = debounce((String value) { 69 | ++callCount; 70 | return value; 71 | }, const Duration(milliseconds: 32)); 72 | 73 | debounced(['a']); 74 | debounced(['b']); 75 | debounced(['c']); 76 | 77 | final result = debounced.flush(); 78 | 79 | expect(result, 'c'); 80 | expect(callCount, 1); 81 | }, 82 | ); 83 | 84 | test( 85 | 'should return if there are functions remaining to get invoked', 86 | () async { 87 | final debounced = debounce(identity, const Duration(milliseconds: 32)); 88 | 89 | debounced(['a']); 90 | 91 | expect(debounced.isPending, true); 92 | 93 | await delay(32); 94 | 95 | expect(debounced.isPending, false); 96 | }, 97 | ); 98 | 99 | test('subsequent debounced calls return the last `func` result', () async { 100 | final debounced = debounce(identity, 32.toDuration()); 101 | debounced(['a']); 102 | 103 | await delay(64); 104 | expect(debounced(['b']), isNot('b')); 105 | 106 | await delay(128); 107 | expect(debounced(['c']), isNot('c')); 108 | }); 109 | 110 | test('should not immediately call `func` when `wait` is `0`', () async { 111 | var callCount = 0; 112 | final debounced = debounce(() { 113 | ++callCount; 114 | }, Duration.zero); 115 | 116 | debounced(); 117 | debounced(); 118 | expect(callCount, 0); 119 | 120 | await delay(5); 121 | expect(callCount, 1); 122 | }); 123 | 124 | test('should apply default options', () async { 125 | var callCount = 0; 126 | final debounced = debounce(() { 127 | callCount++; 128 | }, 32.toDuration()); 129 | 130 | debounced(); 131 | expect(callCount, 0); 132 | 133 | await delay(64); 134 | expect(callCount, 1); 135 | }); 136 | 137 | test('should support a `leading` option', () async { 138 | var callCounts = [0, 0]; 139 | 140 | final withLeading = debounce(() { 141 | callCounts[0]++; 142 | }, 32.toDuration(), leading: true, trailing: false); 143 | 144 | final withLeadingAndTrailing = debounce(() { 145 | callCounts[1]++; 146 | }, 32.toDuration(), leading: true, trailing: true); 147 | 148 | withLeading(); 149 | expect(callCounts[0], 1); 150 | 151 | withLeadingAndTrailing(); 152 | withLeadingAndTrailing(); 153 | expect(callCounts[1], 1); 154 | 155 | await delay(64); 156 | expect(callCounts, [1, 2]); 157 | 158 | withLeading(); 159 | expect(callCounts[0], 2); 160 | }); 161 | 162 | test( 163 | 'subsequent leading debounced calls return the last `func` result', 164 | () async { 165 | final debounced = debounce( 166 | identity, 167 | 32.toDuration(), 168 | leading: true, 169 | trailing: false, 170 | ); 171 | 172 | var results = [ 173 | debounced(['a']), 174 | debounced(['b']) 175 | ]; 176 | 177 | expect(results, ['a', 'a']); 178 | 179 | await delay(64); 180 | results = [ 181 | debounced(['c']), 182 | debounced(['d']) 183 | ]; 184 | expect(results, ['c', 'c']); 185 | }, 186 | ); 187 | 188 | test('should support a `trailing` option', () async { 189 | var withCount = 0; 190 | var withoutCount = 0; 191 | 192 | final withTrailing = debounce(() { 193 | withCount++; 194 | }, 32.toDuration(), trailing: true); 195 | 196 | final withoutTrailing = debounce(() { 197 | withoutCount++; 198 | }, 32.toDuration(), trailing: false); 199 | 200 | withTrailing(); 201 | expect(withCount, 0); 202 | 203 | withoutTrailing(); 204 | expect(withoutCount, 0); 205 | 206 | await delay(64); 207 | expect(withCount, 1); 208 | expect(withoutCount, 0); 209 | }); 210 | 211 | test('should support a `maxWait` option', () async { 212 | var callCount = 0; 213 | 214 | final debounced = debounce(() { 215 | ++callCount; 216 | }, 32.toDuration(), maxWait: 64.toDuration()); 217 | 218 | debounced(); 219 | debounced(); 220 | expect(callCount, 0); 221 | 222 | await delay(128); 223 | expect(callCount, 1); 224 | debounced(); 225 | debounced(); 226 | expect(callCount, 1); 227 | 228 | await delay(256); 229 | expect(callCount, 2); 230 | }); 231 | 232 | test('should support `maxWait` in a tight loop', () async { 233 | var limit = 320; 234 | var withCount = 0; 235 | var withoutCount = 0; 236 | 237 | final withMaxWait = debounce(() { 238 | withCount++; 239 | }, 64.toDuration(), maxWait: 128.toDuration()); 240 | 241 | final withoutMaxWait = debounce(() { 242 | withoutCount++; 243 | }, 96.toDuration()); 244 | 245 | var start = DateTime.now().millisecondsSinceEpoch; 246 | while ((DateTime.now().millisecondsSinceEpoch - start) < limit) { 247 | withMaxWait(); 248 | withoutMaxWait(); 249 | } 250 | var actual = [withoutCount.toBool(), withCount.toBool()]; 251 | 252 | await delay(1); 253 | expect(actual, [false, true]); 254 | }); 255 | 256 | test( 257 | 'should queue a trailing call for subsequent debounced calls after `maxWait`', 258 | () async { 259 | var callCount = 0; 260 | 261 | var debounced = debounce(() { 262 | ++callCount; 263 | }, 200.toDuration(), maxWait: 200.toDuration()); 264 | 265 | debounced(); 266 | 267 | await delay(190); 268 | debounced(); 269 | await delay(200); 270 | debounced(); 271 | await delay(210); 272 | debounced(); 273 | 274 | await delay(500); 275 | expect(callCount, 3); 276 | }, 277 | ); 278 | 279 | test('should cancel `maxDelayed` when `delayed` is invoked', () async { 280 | var callCount = 0; 281 | 282 | final debounced = debounce(() { 283 | callCount++; 284 | }, 32.toDuration(), maxWait: 64.toDuration()); 285 | 286 | debounced(); 287 | 288 | await delay(128); 289 | debounced(); 290 | expect(callCount, 1); 291 | 292 | await delay(192); 293 | expect(callCount, 2); 294 | }); 295 | 296 | test( 297 | 'should invoke the trailing call with the correct arguments', 298 | () async { 299 | var actual; 300 | var callCount = 0; 301 | var object = {}; 302 | 303 | final debounced = debounce((Map object, String value) { 304 | actual = [object, value]; 305 | return ++callCount != 2; 306 | }, 32.toDuration(), leading: true, maxWait: 64.toDuration()); 307 | 308 | while (true) { 309 | if (!(debounced([object, 'a']) as bool)) { 310 | break; 311 | } 312 | } 313 | 314 | await delay(64); 315 | expect(callCount, 2); 316 | expect(actual, [object, 'a']); 317 | }, 318 | ); 319 | }); 320 | } 321 | --------------------------------------------------------------------------------