Skip to content

Commit 4e5c6d0

Browse files
authored
Treat ABORTED writes as retryable (#2247)
* Treat ABORTED writes as retryable This is a port of firebase/firebase-js-sdk#1456. * Regenerate spec test json
1 parent 7485b07 commit 4e5c6d0

File tree

5 files changed

+161
-73
lines changed

5 files changed

+161
-73
lines changed

Firestore/Example/Tests/Remote/FSTDatastoreTests.mm

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,16 @@ @interface FSTDatastoreTests : XCTestCase
2424

2525
@implementation FSTDatastoreTests
2626

27-
- (void)testIsPermanentWriteError {
27+
- (NSError *)errorForCode:(FIRFirestoreErrorCode)code {
28+
return [NSError errorWithDomain:FIRFirestoreErrorDomain code:code userInfo:nil];
29+
}
30+
31+
- (void)testIsPermanentError {
2832
// From GRPCCall -cancel
2933
NSError *error = [NSError errorWithDomain:FIRFirestoreErrorDomain
3034
code:FIRFirestoreErrorCodeCancelled
3135
userInfo:@{NSLocalizedDescriptionKey : @"Canceled by app"}];
32-
XCTAssertFalse([FSTDatastore isPermanentWriteError:error]);
36+
XCTAssertFalse([FSTDatastore isPermanentError:error]);
3337

3438
// From GRPCCall -startNextRead
3539
error = [NSError errorWithDomain:FIRFirestoreErrorDomain
@@ -38,24 +42,37 @@ - (void)testIsPermanentWriteError {
3842
NSLocalizedDescriptionKey :
3943
@"Client does not have enough memory to hold the server response."
4044
}];
41-
XCTAssertFalse([FSTDatastore isPermanentWriteError:error]);
45+
XCTAssertFalse([FSTDatastore isPermanentError:error]);
4246

4347
// From GRPCCall -startWithWriteable
4448
error = [NSError errorWithDomain:FIRFirestoreErrorDomain
4549
code:FIRFirestoreErrorCodeUnavailable
4650
userInfo:@{NSLocalizedDescriptionKey : @"Connectivity lost."}];
47-
XCTAssertFalse([FSTDatastore isPermanentWriteError:error]);
51+
XCTAssertFalse([FSTDatastore isPermanentError:error]);
4852

4953
// User info doesn't matter:
50-
error = [NSError errorWithDomain:FIRFirestoreErrorDomain
51-
code:FIRFirestoreErrorCodeUnavailable
52-
userInfo:nil];
53-
XCTAssertFalse([FSTDatastore isPermanentWriteError:error]);
54+
error = [self errorForCode:FIRFirestoreErrorCodeUnavailable];
55+
XCTAssertFalse([FSTDatastore isPermanentError:error]);
5456

5557
// "unauthenticated" is considered a recoverable error due to expired token.
56-
error = [NSError errorWithDomain:FIRFirestoreErrorDomain
57-
code:FIRFirestoreErrorCodeUnauthenticated
58-
userInfo:nil];
58+
error = [self errorForCode:FIRFirestoreErrorCodeUnauthenticated];
59+
XCTAssertFalse([FSTDatastore isPermanentError:error]);
60+
61+
error = [self errorForCode:FIRFirestoreErrorCodeDataLoss];
62+
XCTAssertTrue([FSTDatastore isPermanentError:error]);
63+
64+
error = [self errorForCode:FIRFirestoreErrorCodeAborted];
65+
XCTAssertTrue([FSTDatastore isPermanentError:error]);
66+
}
67+
68+
- (void)testIsPermanentWriteError {
69+
NSError *error = [self errorForCode:FIRFirestoreErrorCodeUnauthenticated];
70+
XCTAssertFalse([FSTDatastore isPermanentWriteError:error]);
71+
72+
error = [self errorForCode:FIRFirestoreErrorCodeDataLoss];
73+
XCTAssertTrue([FSTDatastore isPermanentWriteError:error]);
74+
75+
error = [self errorForCode:FIRFirestoreErrorCodeAborted];
5976
XCTAssertFalse([FSTDatastore isPermanentWriteError:error]);
6077
}
6178

Firestore/Example/Tests/SpecTests/json/write_spec_test.json

Lines changed: 95 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -5797,9 +5797,9 @@
57975797
}
57985798
]
57995799
},
5800-
"Writes that fail with code aborted are rejected": {
5800+
"Writes that fail with code out-of-range are rejected": {
58015801
"describeName": "Writes:",
5802-
"itName": "Writes that fail with code aborted are rejected",
5802+
"itName": "Writes that fail with code out-of-range are rejected",
58035803
"tags": [],
58045804
"config": {
58055805
"useGarbageCollection": true,
@@ -5864,7 +5864,7 @@
58645864
{
58655865
"failWrite": {
58665866
"error": {
5867-
"code": 10
5867+
"code": 11
58685868
}
58695869
},
58705870
"stateExpect": {
@@ -5903,9 +5903,9 @@
59035903
}
59045904
]
59055905
},
5906-
"Writes that fail with code out-of-range are rejected": {
5906+
"Writes that fail with code unimplemented are rejected": {
59075907
"describeName": "Writes:",
5908-
"itName": "Writes that fail with code out-of-range are rejected",
5908+
"itName": "Writes that fail with code unimplemented are rejected",
59095909
"tags": [],
59105910
"config": {
59115911
"useGarbageCollection": true,
@@ -5970,7 +5970,7 @@
59705970
{
59715971
"failWrite": {
59725972
"error": {
5973-
"code": 11
5973+
"code": 12
59745974
}
59755975
},
59765976
"stateExpect": {
@@ -6009,9 +6009,9 @@
60096009
}
60106010
]
60116011
},
6012-
"Writes that fail with code unimplemented are rejected": {
6012+
"Writes that fail with code data-loss are rejected": {
60136013
"describeName": "Writes:",
6014-
"itName": "Writes that fail with code unimplemented are rejected",
6014+
"itName": "Writes that fail with code data-loss are rejected",
60156015
"tags": [],
60166016
"config": {
60176017
"useGarbageCollection": true,
@@ -6076,7 +6076,7 @@
60766076
{
60776077
"failWrite": {
60786078
"error": {
6079-
"code": 12
6079+
"code": 15
60806080
}
60816081
},
60826082
"stateExpect": {
@@ -6115,9 +6115,9 @@
61156115
}
61166116
]
61176117
},
6118-
"Writes that fail with code data-loss are rejected": {
6118+
"Writes that fail with code resource_exhausted are not rejected": {
61196119
"describeName": "Writes:",
6120-
"itName": "Writes that fail with code data-loss are rejected",
6120+
"itName": "Writes that fail with code resource_exhausted are not rejected",
61216121
"tags": [],
61226122
"config": {
61236123
"useGarbageCollection": true,
@@ -6182,48 +6182,16 @@
61826182
{
61836183
"failWrite": {
61846184
"error": {
6185-
"code": 15
6186-
}
6187-
},
6188-
"stateExpect": {
6189-
"userCallbacks": {
6190-
"acknowledgedDocs": [],
6191-
"rejectedDocs": [
6192-
"collection/key"
6193-
]
6194-
}
6195-
},
6196-
"expect": [
6197-
{
6198-
"query": {
6199-
"path": "collection/key",
6200-
"filters": [],
6201-
"orderBys": []
6202-
},
6203-
"removed": [
6204-
{
6205-
"key": "collection/key",
6206-
"version": 0,
6207-
"value": {
6208-
"foo": "bar"
6209-
},
6210-
"options": {
6211-
"hasLocalMutations": true,
6212-
"hasCommittedMutations": false
6213-
}
6214-
}
6215-
],
6216-
"errorCode": 0,
6217-
"fromCache": true,
6218-
"hasPendingWrites": false
6219-
}
6220-
]
6185+
"code": 8
6186+
},
6187+
"keepInQueue": true
6188+
}
62216189
}
62226190
]
62236191
},
6224-
"Writes that fail with code resource_exhausted are not rejected": {
6192+
"Writes that fail with code aborted are retried": {
62256193
"describeName": "Writes:",
6226-
"itName": "Writes that fail with code resource_exhausted are not rejected",
6194+
"itName": "Writes that fail with code aborted are retried",
62276195
"tags": [],
62286196
"config": {
62296197
"useGarbageCollection": true,
@@ -6288,10 +6256,87 @@
62886256
{
62896257
"failWrite": {
62906258
"error": {
6291-
"code": 8
6259+
"code": 10
62926260
},
62936261
"keepInQueue": true
62946262
}
6263+
},
6264+
{
6265+
"writeAck": {
6266+
"version": 1000
6267+
},
6268+
"stateExpect": {
6269+
"userCallbacks": {
6270+
"acknowledgedDocs": [
6271+
"collection/key"
6272+
],
6273+
"rejectedDocs": []
6274+
}
6275+
}
6276+
},
6277+
{
6278+
"watchAck": [
6279+
2
6280+
]
6281+
},
6282+
{
6283+
"watchEntity": {
6284+
"docs": [
6285+
{
6286+
"key": "collection/key",
6287+
"version": 1000,
6288+
"value": {
6289+
"foo": "bar"
6290+
},
6291+
"options": {
6292+
"hasLocalMutations": false,
6293+
"hasCommittedMutations": false
6294+
}
6295+
}
6296+
],
6297+
"targets": [
6298+
2
6299+
]
6300+
}
6301+
},
6302+
{
6303+
"watchCurrent": [
6304+
[
6305+
2
6306+
],
6307+
"resume-token-1000"
6308+
]
6309+
},
6310+
{
6311+
"watchSnapshot": {
6312+
"version": 1000,
6313+
"targetIds": []
6314+
},
6315+
"expect": [
6316+
{
6317+
"query": {
6318+
"path": "collection/key",
6319+
"filters": [],
6320+
"orderBys": []
6321+
},
6322+
"metadata": [
6323+
{
6324+
"key": "collection/key",
6325+
"version": 1000,
6326+
"value": {
6327+
"foo": "bar"
6328+
},
6329+
"options": {
6330+
"hasLocalMutations": false,
6331+
"hasCommittedMutations": false
6332+
}
6333+
}
6334+
],
6335+
"errorCode": 0,
6336+
"fromCache": false,
6337+
"hasPendingWrites": false
6338+
}
6339+
]
62956340
}
62966341
]
62976342
},

Firestore/Source/Remote/FSTDatastore.h

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,25 @@ NS_ASSUME_NONNULL_BEGIN
7676
/** Returns YES if the given error is a GRPC ABORTED error. **/
7777
+ (BOOL)isAbortedError:(NSError *)error;
7878

79-
/** Returns YES if the given error indicates the RPC associated with it may not be retried. */
79+
/**
80+
* Determines whether an error code represents a permanent error when received in response to a
81+
* non-write operation.
82+
*
83+
* See +isPermanentWriteError for classifying write errors.
84+
*/
85+
+ (BOOL)isPermanentError:(NSError *)error;
86+
87+
/**
88+
* Determines whether an error code represents a permanent error when received in response to a
89+
* write operation.
90+
*
91+
* Write operations must be handled specially because as of b/119437764, ABORTED errors on the write
92+
* stream should be retried too (even though ABORTED errors are not generally retryable).
93+
*
94+
* Note that during the initial handshake on the write stream an ABORTED error signals that we
95+
* should discard our stream token (i.e. it is permanent). This means a handshake error should be
96+
* classified with isPermanentError, above.
97+
*/
8098
+ (BOOL)isPermanentWriteError:(NSError *)error;
8199

82100
/** Looks up a list of documents in datastore. */

Firestore/Source/Remote/FSTDatastore.mm

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -144,18 +144,18 @@ + (BOOL)isAbortedError:(NSError *)error {
144144
return error.code == FIRFirestoreErrorCodeAborted;
145145
}
146146

147-
+ (BOOL)isPermanentWriteError:(NSError *)error {
147+
+ (BOOL)isPermanentError:(NSError *)error {
148148
HARD_ASSERT([error.domain isEqualToString:FIRFirestoreErrorDomain],
149-
"isPerminanteWriteError: only works with errors emitted by FSTDatastore.");
149+
"isPermanentError: only works with errors emitted by FSTDatastore.");
150150
switch (error.code) {
151151
case FIRFirestoreErrorCodeCancelled:
152152
case FIRFirestoreErrorCodeUnknown:
153153
case FIRFirestoreErrorCodeDeadlineExceeded:
154154
case FIRFirestoreErrorCodeResourceExhausted:
155155
case FIRFirestoreErrorCodeInternal:
156156
case FIRFirestoreErrorCodeUnavailable:
157-
// Unauthenticated means something went wrong with our token and we need to retry with new
158-
// credentials which will happen automatically.
157+
// Unauthenticated means something went wrong with our token and we need to retry with new
158+
// credentials which will happen automatically.
159159
case FIRFirestoreErrorCodeUnauthenticated:
160160
return NO;
161161
case FIRFirestoreErrorCodeInvalidArgument:
@@ -164,9 +164,9 @@ + (BOOL)isPermanentWriteError:(NSError *)error {
164164
case FIRFirestoreErrorCodePermissionDenied:
165165
case FIRFirestoreErrorCodeFailedPrecondition:
166166
case FIRFirestoreErrorCodeAborted:
167-
// Aborted might be retried in some scenarios, but that is dependant on
168-
// the context and should handled individually by the calling code.
169-
// See https://cloud.google.com/apis/design/errors
167+
// Aborted might be retried in some scenarios, but that is dependant on
168+
// the context and should handled individually by the calling code.
169+
// See https://cloud.google.com/apis/design/errors
170170
case FIRFirestoreErrorCodeOutOfRange:
171171
case FIRFirestoreErrorCodeUnimplemented:
172172
case FIRFirestoreErrorCodeDataLoss:
@@ -176,6 +176,10 @@ + (BOOL)isPermanentWriteError:(NSError *)error {
176176
}
177177
}
178178

179+
+ (BOOL)isPermanentWriteError:(NSError *)error {
180+
return [self isPermanentError:error] && error.code != FIRFirestoreErrorCodeAborted;
181+
}
182+
179183
- (void)commitMutations:(NSArray<FSTMutation *> *)mutations
180184
completion:(FSTVoidErrorBlock)completion {
181185
_datastore->CommitMutations(mutations, completion);

Firestore/Source/Remote/FSTRemoteStore.mm

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -575,20 +575,24 @@ - (void)writeStreamWasInterruptedWithError:(nullable NSError *)error {
575575

576576
- (void)handleHandshakeError:(NSError *)error {
577577
HARD_ASSERT(error, "Handling write error with status OK.");
578-
// Reset the token if it's a permanent error or the error code is ABORTED, signaling the write
579-
// stream is no longer valid.
580-
if ([FSTDatastore isPermanentWriteError:error] || [FSTDatastore isAbortedError:error]) {
578+
// Reset the token if it's a permanent error, signaling the write stream is
579+
// no longer valid. Note that the handshake does not count as a write: see
580+
// comments on isPermanentWriteError for details.
581+
if ([FSTDatastore isPermanentError:error]) {
581582
NSString *token = [_writeStream->GetLastStreamToken() base64EncodedStringWithOptions:0];
582583
LOG_DEBUG("FSTRemoteStore %s error before completed handshake; resetting stream token %s: %s",
583584
(__bridge void *)self, token, error);
584585
_writeStream->SetLastStreamToken(nil);
585586
[self.localStore setLastStreamToken:nil];
587+
} else {
588+
// Some other error, don't reset stream token. Our stream logic will just retry with exponential
589+
// backoff.
586590
}
587591
}
588592

589593
- (void)handleWriteError:(NSError *)error {
590594
HARD_ASSERT(error, "Handling write error with status OK.");
591-
// Only handle permanent error. If it's transient, just let the retry logic kick in.
595+
// Only handle permanent errors here. If it's transient, just let the retry logic kick in.
592596
if (![FSTDatastore isPermanentWriteError:error]) {
593597
return;
594598
}

0 commit comments

Comments
 (0)