From ff652c242bfd69657c090cd9ce14e2ef640b5014 Mon Sep 17 00:00:00 2001 From: developerjamiu Date: Wed, 11 Mar 2026 11:05:31 +0100 Subject: [PATCH 1/2] feat(firestore): add retry support for WriteBatch commit --- .../google_cloud_firestore/write_batch.dart | 43 ++++- .../write_batch_test.dart | 166 ++++++++++++++++++ 2 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 packages/dart_firebase_admin/test/google_cloud_firestore/write_batch_test.dart diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/write_batch.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/write_batch.dart index 1aebf89a..1005e148 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/write_batch.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/write_batch.dart @@ -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. diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/write_batch_test.dart b/packages/dart_firebase_admin/test/google_cloud_firestore/write_batch_test.dart new file mode 100644 index 00000000..58f32b8a --- /dev/null +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/write_batch_test.dart @@ -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().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().having( + (e) => e.errorCode.statusCode, + 'statusCode', + StatusCode.invalidArgument, + ), + ), + ); + expect(callCount, 1); + }); + }); +} From 819e16e57f060cb8a730d99175989aa453aab627 Mon Sep 17 00:00:00 2001 From: developerjamiu Date: Wed, 11 Mar 2026 11:10:43 +0100 Subject: [PATCH 2/2] chore: Update changelog --- packages/dart_firebase_admin/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/dart_firebase_admin/CHANGELOG.md b/packages/dart_firebase_admin/CHANGELOG.md index 04983473..f904090a 100644 --- a/packages/dart_firebase_admin/CHANGELOG.md +++ b/packages/dart_firebase_admin/CHANGELOG.md @@ -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`