Skip to content
Open
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
4 changes: 4 additions & 0 deletions packages/dart_firebase_admin/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## Unreleased minor

- Added retry support for `WriteBatch.commit()` on transient errors (`ABORTED`, `UNAVAILABLE`, `RESOURCE_EXHAUSTED`).

## 0.4.1 - 2025-03-21

- Bump intl to `0.20.0`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,43 @@ class WriteBatch {
writes: _operations.map((op) => op.op()).toList(),
);

return firestore._client.v1((client) async {
return client.projects.databases.documents.commit(
request,
firestore._formattedDatabaseName,
);
});
if (transactionId != null) {
return firestore._client.v1((client) async {
return client.projects.databases.documents.commit(
request,
firestore._formattedDatabaseName,
);
});
}

const retryCodes = [
StatusCode.aborted,
...StatusCode.commitRetryCodes,
];

final backoff = ExponentialBackoff();
FirebaseFirestoreAdminException? lastError;

for (var attempt = 0;
attempt <= ExponentialBackoff.maxRetryAttempts;
attempt++) {
try {
await _maybeBackoff(backoff, lastError);
return await firestore._client.v1((client) async {
return client.projects.databases.documents.commit(
request,
firestore._formattedDatabaseName,
);
});
} on FirebaseFirestoreAdminException catch (e) {
lastError = e;
if (!retryCodes.contains(e.errorCode.statusCode)) {
rethrow;
}
}
}

throw lastError!;
}

///Resets the WriteBatch and dequeues all pending operations.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import 'dart:convert';

import 'package:dart_firebase_admin/firestore.dart';
import 'package:dart_firebase_admin/src/google_cloud_firestore/status_code.dart';
import 'package:http/http.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';

import '../mock.dart';
import 'util/helpers.dart';

const _jsonHeaders = {'content-type': 'application/json; charset=utf-8'};

StreamedResponse _errorResponse(int httpCode, String status, String message) {
return StreamedResponse(
Stream.value(
utf8.encode(
jsonEncode({
'error': {'code': httpCode, 'status': status, 'message': message},
}),
),
),
httpCode,
headers: _jsonHeaders,
);
}

StreamedResponse _successResponse() {
return StreamedResponse(
Stream.value(
utf8.encode(
jsonEncode({
'commitTime': '2024-01-01T00:00:00.000Z',
'writeResults': [
{'updateTime': '2024-01-01T00:00:00.000Z'},
],
}),
),
),
200,
headers: _jsonHeaders,
);
}

void main() {
setUpAll(registerFallbacks);

group('WriteBatch', () {
test('retries on UNAVAILABLE and succeeds', () async {
var callCount = 0;
final clientMock = ClientMock();

when(() => clientMock.send(any())).thenAnswer((_) {
callCount++;
if (callCount == 1) {
return Future.value(
_errorResponse(503, 'UNAVAILABLE', 'Service unavailable'),
);
}
return Future.value(_successResponse());
});

final app = createApp(client: clientMock);
final firestore = Firestore(app);

await firestore.doc('test/retry').set({'value': 1});
expect(callCount, 2);
});

test('retries on ABORTED and succeeds', () async {
var callCount = 0;
final clientMock = ClientMock();

when(() => clientMock.send(any())).thenAnswer((_) {
callCount++;
if (callCount == 1) {
return Future.value(
_errorResponse(409, 'ABORTED', 'Transaction lock timeout'),
);
}
return Future.value(_successResponse());
});

final app = createApp(client: clientMock);
final firestore = Firestore(app);

await firestore.doc('test/retry').set({'value': 1});
expect(callCount, 2);
});

test('succeeds after multiple transient failures', () async {
var callCount = 0;
final clientMock = ClientMock();

when(() => clientMock.send(any())).thenAnswer((_) {
callCount++;
if (callCount <= 3) {
return Future.value(
_errorResponse(503, 'UNAVAILABLE', 'Service unavailable'),
);
}
return Future.value(_successResponse());
});

final app = createApp(client: clientMock);
final firestore = Firestore(app);

await firestore.doc('test/retry').set({'value': 1});
expect(callCount, 4);
});

test('does not retry on PERMISSION_DENIED', () async {
var callCount = 0;
final clientMock = ClientMock();

when(() => clientMock.send(any())).thenAnswer((_) {
callCount++;
return Future.value(
_errorResponse(403, 'PERMISSION_DENIED', 'Missing permissions'),
);
});

final app = createApp(client: clientMock);
final firestore = Firestore(app);

await expectLater(
() => firestore.doc('test/retry').set({'value': 1}),
throwsA(
isA<FirebaseFirestoreAdminException>().having(
(e) => e.errorCode.statusCode,
'statusCode',
StatusCode.permissionDenied,
),
),
);
expect(callCount, 1);
});

test('does not retry on INVALID_ARGUMENT', () async {
var callCount = 0;
final clientMock = ClientMock();

when(() => clientMock.send(any())).thenAnswer((_) {
callCount++;
return Future.value(
_errorResponse(400, 'INVALID_ARGUMENT', 'Invalid field'),
);
});

final app = createApp(client: clientMock);
final firestore = Firestore(app);

await expectLater(
() => firestore.doc('test/retry').set({'value': 1}),
throwsA(
isA<FirebaseFirestoreAdminException>().having(
(e) => e.errorCode.statusCode,
'statusCode',
StatusCode.invalidArgument,
),
),
);
expect(callCount, 1);
});
});
}
Loading