Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/google_cloud_firestore/lib/src/backoff.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class ExponentialBackoff {
_currentBaseMs = _currentBaseMs.clamp(initialDelayMs, maxDelayMs);
_retryCount += 1;

_awaitingBackoffCompletion = true;
await Future<void>.delayed(Duration(milliseconds: delayWithJitterMs));
_awaitingBackoffCompletion = false;
}
Expand Down
202 changes: 202 additions & 0 deletions packages/google_cloud_firestore/test/backoff_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// ignore_for_file: invalid_use_of_internal_member

import 'package:google_cloud_firestore/src/backoff.dart';
import 'package:test/test.dart';

void main() {
group('ExponentialBackoff', () {
test('first backoffAndWait() has no delay', () async {
final backoff = ExponentialBackoff(
options: const ExponentialBackoffSetting(
initialDelayMs: 1000,
jitterFactor: 0,
),
);
final before = DateTime.now();
await backoff.backoffAndWait();
final elapsedMs = DateTime.now().difference(before).inMilliseconds;
expect(elapsedMs, lessThan(100));
});

test('respects the initial retry delay on second call', () async {
final backoff = ExponentialBackoff(
options: const ExponentialBackoffSetting(
initialDelayMs: 50,
backoffFactor: 2,
maxDelayMs: 5000,
jitterFactor: 0,
),
);
await backoff.backoffAndWait();

final before = DateTime.now();
await backoff.backoffAndWait();
final elapsedMs = DateTime.now().difference(before).inMilliseconds;
expect(elapsedMs, greaterThanOrEqualTo(50));
});

test('exponentially increases the delay', () async {
final backoff = ExponentialBackoff(
options: const ExponentialBackoffSetting(
initialDelayMs: 10,
backoffFactor: 2,
maxDelayMs: 5000,
jitterFactor: 0,
),
);
await backoff.backoffAndWait();
await backoff.backoffAndWait();

final before = DateTime.now();
await backoff.backoffAndWait();
final elapsedMs = DateTime.now().difference(before).inMilliseconds;
expect(elapsedMs, greaterThanOrEqualTo(20));
});

test('delay increases until maximum then stays capped', () async {
final backoff = ExponentialBackoff(
options: const ExponentialBackoffSetting(
initialDelayMs: 10,
backoffFactor: 2,
maxDelayMs: 35,
jitterFactor: 0,
),
);
await backoff.backoffAndWait();
await backoff.backoffAndWait();
await backoff.backoffAndWait();

final before = DateTime.now();
await backoff.backoffAndWait();
final elapsed = DateTime.now().difference(before).inMilliseconds;
expect(elapsed, greaterThanOrEqualTo(35));

final before2 = DateTime.now();
await backoff.backoffAndWait();
final elapsed2 = DateTime.now().difference(before2).inMilliseconds;
expect(elapsed2, greaterThanOrEqualTo(35));
});

test('reset() resets delay and retry count to zero', () async {
final backoff = ExponentialBackoff(
options: const ExponentialBackoffSetting(
initialDelayMs: 50,
backoffFactor: 2,
maxDelayMs: 5000,
jitterFactor: 0,
),
);
await backoff.backoffAndWait();
await backoff.backoffAndWait();

backoff.reset();

final before = DateTime.now();
await backoff.backoffAndWait();
final elapsedMs = DateTime.now().difference(before).inMilliseconds;
expect(elapsedMs, lessThan(25));
});

test('resetToMax() causes next delay to use maxDelayMs', () async {
final backoff = ExponentialBackoff(
options: const ExponentialBackoffSetting(
initialDelayMs: 10,
backoffFactor: 2,
maxDelayMs: 50,
jitterFactor: 0,
),
);
await backoff.backoffAndWait();
backoff.resetToMax();

final before = DateTime.now();
await backoff.backoffAndWait();
final elapsedMs = DateTime.now().difference(before).inMilliseconds;
expect(elapsedMs, greaterThanOrEqualTo(50));
});

test('applies jitter within expected variance bounds', () async {
final backoff = ExponentialBackoff(
options: const ExponentialBackoffSetting(
initialDelayMs: 100,
backoffFactor: 1,
maxDelayMs: 100,
jitterFactor: 0.5,
),
);
await backoff.backoffAndWait();

final before = DateTime.now();
await backoff.backoffAndWait();
final elapsedMs = DateTime.now().difference(before).inMilliseconds;
expect(elapsedMs, greaterThanOrEqualTo(50));
expect(elapsedMs, lessThan(200));
});

test(
'tracks retry attempts and throws after maxRetryAttempts+1 calls',
() async {
final backoff = ExponentialBackoff(
options: const ExponentialBackoffSetting(
initialDelayMs: 0,
maxDelayMs: 0,
jitterFactor: 0,
),
);
for (var i = 0; i <= ExponentialBackoff.maxRetryAttempts; i++) {
await backoff.backoffAndWait();
}
await expectLater(
backoff.backoffAndWait(),
throwsA(
isA<Exception>().having(
(e) => e.toString(),
'message',
contains('Exceeded maximum number of retries'),
),
),
);
},
);

test('reset() clears retry count so attempts can start fresh', () async {
final backoff = ExponentialBackoff(
options: const ExponentialBackoffSetting(
initialDelayMs: 0,
maxDelayMs: 0,
jitterFactor: 0,
),
);
for (var i = 0; i <= ExponentialBackoff.maxRetryAttempts; i++) {
await backoff.backoffAndWait();
}
backoff.reset();
await expectLater(backoff.backoffAndWait(), completes);
});

test('cannot queue two backoffAndWait() calls simultaneously', () async {
final backoff = ExponentialBackoff(
options: const ExponentialBackoffSetting(
initialDelayMs: 50,
backoffFactor: 1,
maxDelayMs: 50,
jitterFactor: 0,
),
);
await backoff.backoffAndWait();

final future1 = backoff.backoffAndWait();
expect(
backoff.backoffAndWait,
throwsA(
isA<Exception>().having(
(e) => e.toString(),
'message',
contains('already in progress'),
),
),
);
await future1;
});
});
}
65 changes: 65 additions & 0 deletions packages/google_cloud_firestore/test/rate_limiter_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// ignore_for_file: invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

import 'package:google_cloud_firestore/src/firestore.dart';
import 'package:test/test.dart';

void main() {
group('RateLimiter', () {
group('accepts and rejects requests based on capacity', () {
test('initial available tokens equal the initial capacity', () {
final limiter = RateLimiter(500, 1.5, 5 * 60 * 1000, 1000 * 1000);
expect(limiter.availableTokens, closeTo(500, 1));
});

test('tryMakeRequest succeeds when within available capacity', () {
final limiter = RateLimiter(500, 1.5, 5 * 60 * 1000, 1000 * 1000);
expect(limiter.tryMakeRequest(500), isTrue);
});

test('tryMakeRequest deducts tokens from available balance', () {
final limiter = RateLimiter(500, 1.5, 5 * 60 * 1000, 1000 * 1000);
limiter.tryMakeRequest(200);
expect(limiter.availableTokens, closeTo(300, 1));
});

test(
'tryMakeRequest returns false when request exceeds available tokens',
() {
final limiter = RateLimiter(500, 1.5, 5 * 60 * 1000, 1000 * 1000);
limiter.tryMakeRequest(500);
expect(limiter.tryMakeRequest(1), isFalse);
},
);
});

group('getNextRequestDelayMs()', () {
test('returns 0 when request is exactly equal to available tokens', () {
final limiter = RateLimiter(500, 1.5, 5 * 60 * 1000, 1000 * 1000);
expect(limiter.getNextRequestDelayMs(500), 0);
});

test('returns 0 when request is less than available tokens', () {
final limiter = RateLimiter(500, 1.5, 5 * 60 * 1000, 1000 * 1000);
expect(limiter.getNextRequestDelayMs(100), 0);
});

test('returns -1 when request exceeds maximum capacity', () {
final limiter = RateLimiter(500, 1.5, 5 * 60 * 1000, 500);
expect(limiter.getNextRequestDelayMs(501), -1);
});
});

group('calculateCapacity()', () {
test('maximumCapacity getter returns configured maximum', () {
final limiter = RateLimiter(500, 1.5, 5 * 60 * 1000, 10000);
expect(limiter.maximumCapacity, 10000);
});

test('maximumCapacity uses 500/50/5 default BulkWriter limits', () {
final limiter = RateLimiter(500, 1.5, 5 * 60 * 1000, 10000);
expect(limiter.maximumCapacity, 10000);
expect(limiter.availableTokens, closeTo(500, 1));
});
});
});
}
Loading
Loading