Skip to content

Commit ba17c8b

Browse files
feat(firestore): add retry support for WriteBatch commit (#188)
1 parent 63ba6a4 commit ba17c8b

3 files changed

Lines changed: 187 additions & 10 deletions

File tree

packages/google_cloud_firestore/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## 0.5.1
22

3+
- Added retry support for `WriteBatch.commit()` on transient errors (`ABORTED`, `UNAVAILABLE`, `RESOURCE_EXHAUSTED`).
34
- Added an example.
45
- Added a more detailed project description.
56
- Update dependency `meta: ^1.17.0` to allow workspaces with stable Flutter.

packages/google_cloud_firestore/lib/src/write_batch.dart

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -116,17 +116,44 @@ class WriteBatch {
116116
}) async {
117117
_commited = true;
118118

119-
return firestore._firestoreClient.v1((api, projectId) async {
120-
final request = firestore_v1.CommitRequest(
121-
transaction: transactionId,
122-
writes: _operations.map((op) => op.op()).toList(),
123-
);
119+
final request = firestore_v1.CommitRequest(
120+
transaction: transactionId,
121+
writes: _operations.map((op) => op.op()).toList(),
122+
);
124123

125-
return api.projects.databases.documents.commit(
126-
request,
127-
firestore._formattedDatabaseName,
128-
);
129-
});
124+
if (transactionId != null) {
125+
return firestore._firestoreClient.v1((api, projectId) async {
126+
return api.projects.databases.documents.commit(
127+
request,
128+
firestore._formattedDatabaseName,
129+
);
130+
});
131+
}
132+
133+
const retryCodes = StatusCode.resourceExhaustedAbortedUnavailable;
134+
const maxAttempts = 5;
135+
136+
final backoff = ExponentialBackoff();
137+
FirestoreException? lastError;
138+
139+
for (var attempt = 0; attempt < maxAttempts; attempt++) {
140+
try {
141+
await _maybeBackoff(backoff, lastError);
142+
return await firestore._firestoreClient.v1((api, projectId) async {
143+
return api.projects.databases.documents.commit(
144+
request,
145+
firestore._formattedDatabaseName,
146+
);
147+
});
148+
} on FirestoreException catch (e) {
149+
lastError = e;
150+
if (!retryCodes.contains(e.errorCode.statusCode)) {
151+
rethrow;
152+
}
153+
}
154+
}
155+
156+
throw lastError!;
130157
}
131158

132159
///Resets the WriteBatch and dequeues all pending operations.

packages/google_cloud_firestore/test/unit/write_batch_test.dart

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,46 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
import 'dart:convert';
16+
import 'dart:io';
17+
1518
import 'package:google_cloud_firestore/google_cloud_firestore.dart';
1619
import 'package:test/test.dart' hide throwsArgumentError;
1720

1821
import '../fixtures/helpers.dart';
1922

23+
void _writeRetryError(
24+
HttpRequest request,
25+
int code,
26+
String status,
27+
String message,
28+
) {
29+
request.response
30+
..statusCode = code
31+
..headers.contentType = ContentType.json
32+
..write(
33+
jsonEncode({
34+
'error': {'code': code, 'status': status, 'message': message},
35+
}),
36+
);
37+
request.response.close();
38+
}
39+
40+
void _writeCommitSuccess(HttpRequest request) {
41+
request.response
42+
..statusCode = 200
43+
..headers.contentType = ContentType.json
44+
..write(
45+
jsonEncode({
46+
'commitTime': '2024-01-01T00:00:00.000Z',
47+
'writeResults': [
48+
{'updateTime': '2024-01-01T00:00:00.000Z'},
49+
],
50+
}),
51+
);
52+
request.response.close();
53+
}
54+
2055
void main() {
2156
group('WriteBatch', () {
2257
late Firestore firestore;
@@ -341,4 +376,118 @@ void main() {
341376
});
342377
});
343378
});
379+
380+
group('WriteBatch retry', () {
381+
late HttpServer server;
382+
late Firestore firestore;
383+
late int callCount;
384+
late void Function(HttpRequest request, int callNumber) handler;
385+
386+
setUp(() async {
387+
callCount = 0;
388+
handler = (_, _) {};
389+
390+
server = await HttpServer.bind('localhost', 0);
391+
server.listen((request) async {
392+
await request.drain<void>();
393+
callCount++;
394+
handler(request, callCount);
395+
});
396+
397+
firestore = Firestore(
398+
settings: Settings(
399+
projectId: 'test-project',
400+
environmentOverride: {
401+
'FIRESTORE_EMULATOR_HOST': 'localhost:${server.port}',
402+
'GOOGLE_CLOUD_PROJECT': 'test-project',
403+
},
404+
),
405+
);
406+
});
407+
408+
tearDown(() async {
409+
await firestore.terminate();
410+
await server.close(force: true);
411+
});
412+
413+
test('retries on UNAVAILABLE and succeeds', () async {
414+
handler = (request, callNumber) {
415+
if (callNumber == 1) {
416+
_writeRetryError(request, 503, 'UNAVAILABLE', 'Service unavailable');
417+
} else {
418+
_writeCommitSuccess(request);
419+
}
420+
};
421+
422+
await firestore.doc('test/retry').set({'value': 1});
423+
expect(callCount, 2);
424+
});
425+
426+
test('retries on ABORTED and succeeds', () async {
427+
handler = (request, callNumber) {
428+
if (callNumber == 1) {
429+
_writeRetryError(request, 409, 'ABORTED', 'Transaction lock timeout');
430+
} else {
431+
_writeCommitSuccess(request);
432+
}
433+
};
434+
435+
await firestore.doc('test/retry').set({'value': 1});
436+
expect(callCount, 2);
437+
});
438+
439+
test('succeeds after multiple transient failures', () async {
440+
handler = (request, callNumber) {
441+
if (callNumber <= 3) {
442+
_writeRetryError(request, 503, 'UNAVAILABLE', 'Service unavailable');
443+
} else {
444+
_writeCommitSuccess(request);
445+
}
446+
};
447+
448+
await firestore.doc('test/retry').set({'value': 1});
449+
expect(callCount, 4);
450+
});
451+
452+
test('does not retry on PERMISSION_DENIED', () async {
453+
handler = (request, _) {
454+
_writeRetryError(
455+
request,
456+
403,
457+
'PERMISSION_DENIED',
458+
'Missing permissions',
459+
);
460+
};
461+
462+
await expectLater(
463+
() => firestore.doc('test/retry').set({'value': 1}),
464+
throwsA(
465+
isA<FirestoreException>().having(
466+
(e) => e.errorCode,
467+
'errorCode',
468+
FirestoreClientErrorCode.permissionDenied,
469+
),
470+
),
471+
);
472+
expect(callCount, 1);
473+
});
474+
475+
test('does not retry on INVALID_ARGUMENT', () async {
476+
handler = (request, _) {
477+
_writeRetryError(request, 400, 'INVALID_ARGUMENT', 'Invalid field');
478+
};
479+
480+
await expectLater(
481+
() => firestore.doc('test/retry').set({'value': 1}),
482+
throwsA(
483+
isA<FirestoreException>().having(
484+
(e) => e.errorCode,
485+
'errorCode',
486+
FirestoreClientErrorCode.invalidArgument,
487+
),
488+
),
489+
);
490+
expect(callCount, 1);
491+
});
492+
});
344493
}

0 commit comments

Comments
 (0)