@mdbase-dev/connect 0.1.0-beta.59 → 0.1.0-beta.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser/integrity.json +1 -1
- package/dist/browser/mdbase-connect.min.js +3 -3
- package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
- package/dist/connection-transport.d.ts +1 -5
- package/dist/connection-transport.js +34 -48
- package/dist/direct-access.d.ts +3 -0
- package/dist/direct-access.js +29 -0
- package/dist/file-transfer-internals.js +6 -2
- package/dist/local-file-transport.js +30 -14
- package/dist/transient-retry.d.ts +22 -0
- package/dist/transient-retry.js +59 -0
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
sha384-
|
|
1
|
+
sha384-8q/ad0vEZFISbJVmcgiwghE8Zi5/u4kBtZPskPy+cHhXMEwZoHGTiGdujLx57o8/
|
|
@@ -32,11 +32,7 @@ export declare class ConnectionTransport {
|
|
|
32
32
|
requestDirectAccess(options?: ConnectRequestOptions): Promise<DirectAccessStatus>;
|
|
33
33
|
private requestDirectAccessWithinBudget;
|
|
34
34
|
disableDirectAccess(): void;
|
|
35
|
-
/**
|
|
36
|
-
* Return one offline-replication transport regardless of whether the
|
|
37
|
-
* authority is remote, directly connected, or relay connected.
|
|
38
|
-
* Authority credentials and routing stay private inside the SDK.
|
|
39
|
-
*/
|
|
35
|
+
/** Return pending writes without exposing authority transport credentials. */
|
|
40
36
|
pendingMutations(): readonly PendingMutationSummary[];
|
|
41
37
|
pendingMutation(requestId: string): PendingMutationSummary | null;
|
|
42
38
|
recoverPendingMutation<Result>(requestId: string, options?: ConnectRequestOptions): Promise<Result>;
|
|
@@ -10,6 +10,8 @@ import { readStoredToken } from "./stored-token.js";
|
|
|
10
10
|
import { withCooperativeRequestBudget, withRequestBudget } from "./request-budget.js";
|
|
11
11
|
import { sendAuthoritySyncRequest } from "./authority-sync-request.js";
|
|
12
12
|
import { freshEncryptedRequest } from "./fresh-encrypted-request.js";
|
|
13
|
+
import { isExplicitConnectorBusyResponse, retryExplicitConnectorBusy } from "./transient-retry.js";
|
|
14
|
+
import { probeLoopbackAccess, tokenSupportsDirectAccess } from "./direct-access.js";
|
|
13
15
|
export class ConnectionTransport {
|
|
14
16
|
serverUrl;
|
|
15
17
|
storage;
|
|
@@ -63,9 +65,7 @@ export class ConnectionTransport {
|
|
|
63
65
|
get route() {
|
|
64
66
|
return this.currentToken()?.authority ? "remote" : this.currentRoute;
|
|
65
67
|
}
|
|
66
|
-
notifyStorageChanged() {
|
|
67
|
-
this.emitConnection();
|
|
68
|
-
}
|
|
68
|
+
notifyStorageChanged() { this.emitConnection(); }
|
|
69
69
|
async checkDirectAccess(options = {}) {
|
|
70
70
|
return withRequestBudget(options, this.timeouts.watchStartMs, (budget) => this.checkDirectAccessWithinBudget(budget.signal));
|
|
71
71
|
}
|
|
@@ -99,11 +99,7 @@ export class ConnectionTransport {
|
|
|
99
99
|
this.setDirectStatus("disabled");
|
|
100
100
|
this.setRoute("relay");
|
|
101
101
|
}
|
|
102
|
-
/**
|
|
103
|
-
* Return one offline-replication transport regardless of whether the
|
|
104
|
-
* authority is remote, directly connected, or relay connected.
|
|
105
|
-
* Authority credentials and routing stay private inside the SDK.
|
|
106
|
-
*/
|
|
102
|
+
/** Return pending writes without exposing authority transport credentials. */
|
|
107
103
|
pendingMutations() {
|
|
108
104
|
return this.storedPendingMutations().map((pending) => ({
|
|
109
105
|
requestId: pending.requestId,
|
|
@@ -136,7 +132,7 @@ export class ConnectionTransport {
|
|
|
136
132
|
signal: budget.signal
|
|
137
133
|
}));
|
|
138
134
|
}
|
|
139
|
-
async performOperationWithinBudget(operation, input, options, storedPending, freshReadRetried = false) {
|
|
135
|
+
async performOperationWithinBudget(operation, input, options, storedPending, freshReadRetried = false, connectorBusyRetries = 0, knownRejectedMutationRetry = false) {
|
|
140
136
|
throwIfCancelled(options.signal);
|
|
141
137
|
let token = this.currentToken();
|
|
142
138
|
if (!token)
|
|
@@ -160,7 +156,7 @@ export class ConnectionTransport {
|
|
|
160
156
|
?? (isMutation(operation, input) ? crypto.randomUUID() : undefined);
|
|
161
157
|
let attempt;
|
|
162
158
|
try {
|
|
163
|
-
attempt = await this.sendOperation(token, operation, input, tryDirect, options, pendingRequestId);
|
|
159
|
+
attempt = await this.sendOperation(token, operation, input, tryDirect, options, pendingRequestId, knownRejectedMutationRetry);
|
|
164
160
|
}
|
|
165
161
|
catch (error) {
|
|
166
162
|
throw operationTransportError(error, options.signal, pendingRequestId !== undefined && this.storedPendingMutation(pendingRequestId) !== null
|
|
@@ -181,7 +177,7 @@ export class ConnectionTransport {
|
|
|
181
177
|
token = await this.refreshAuthorization(options.signal);
|
|
182
178
|
tryDirect = await this.shouldAttemptDirect(token);
|
|
183
179
|
try {
|
|
184
|
-
attempt = await this.sendOperation(token, operation, input, tryDirect, options, pendingRequestId);
|
|
180
|
+
attempt = await this.sendOperation(token, operation, input, tryDirect, options, pendingRequestId, knownRejectedMutationRetry);
|
|
185
181
|
}
|
|
186
182
|
catch (error) {
|
|
187
183
|
throw operationTransportError(error, options.signal, pendingRequestId !== undefined && this.storedPendingMutation(pendingRequestId) !== null
|
|
@@ -190,6 +186,15 @@ export class ConnectionTransport {
|
|
|
190
186
|
}
|
|
191
187
|
response = attempt.response;
|
|
192
188
|
}
|
|
189
|
+
const retryBusy = (error) => retryExplicitConnectorBusy({
|
|
190
|
+
error,
|
|
191
|
+
attempt,
|
|
192
|
+
completedRetries: connectorBusyRetries,
|
|
193
|
+
signal: options.signal,
|
|
194
|
+
knownRejectedMutationRetry,
|
|
195
|
+
clearPending: (requestId) => this.clearPendingMutation(requestId),
|
|
196
|
+
retry: (pending, knownRejected) => this.performOperationWithinBudget(operation, input, options, pending, freshReadRetried, connectorBusyRetries + 1, knownRejected)
|
|
197
|
+
});
|
|
193
198
|
let body;
|
|
194
199
|
try {
|
|
195
200
|
body = await decodeJsonResponse(response, "invalid_operation_response", "The collection authority returned a response that is not valid JSON.");
|
|
@@ -201,6 +206,9 @@ export class ConnectionTransport {
|
|
|
201
206
|
}
|
|
202
207
|
if (!response.ok) {
|
|
203
208
|
const error = apiError(body, "operation_failed", "Collection operation failed.", response.status);
|
|
209
|
+
const recovery = await retryBusy(error);
|
|
210
|
+
if (recovery.retried)
|
|
211
|
+
return recovery.result;
|
|
204
212
|
if (error.code === "fresh_request_required"
|
|
205
213
|
&& !attempt.pendingMutation
|
|
206
214
|
&& !freshReadRetried) {
|
|
@@ -280,9 +288,13 @@ export class ConnectionTransport {
|
|
|
280
288
|
throw unknownMutationOutcome(attempt.requestId, new Error("Operation rejection did not contain a canonical problem."));
|
|
281
289
|
throw connectError("invalid_operation_response", "The collection authority returned an invalid operation problem.");
|
|
282
290
|
}
|
|
291
|
+
const error = new MdbaseConnectError(body.problem);
|
|
292
|
+
const recovery = await retryBusy(error);
|
|
293
|
+
if (recovery.retried)
|
|
294
|
+
return recovery.result;
|
|
283
295
|
if (attempt.pendingMutation)
|
|
284
296
|
this.clearPendingMutation(attempt.requestId);
|
|
285
|
-
throw
|
|
297
|
+
throw error;
|
|
286
298
|
}
|
|
287
299
|
if (body.ok !== true || !("result" in body)) {
|
|
288
300
|
if (attempt.pendingMutation)
|
|
@@ -328,7 +340,7 @@ export class ConnectionTransport {
|
|
|
328
340
|
}
|
|
329
341
|
});
|
|
330
342
|
}
|
|
331
|
-
async sendOperation(token, operation, input, tryDirect, options = {}, pendingRequestId) {
|
|
343
|
+
async sendOperation(token, operation, input, tryDirect, options = {}, pendingRequestId, knownRejectedMutationRetry = false) {
|
|
332
344
|
let body = input ?? {};
|
|
333
345
|
let encryptedRequest;
|
|
334
346
|
let pendingMutation = false;
|
|
@@ -347,7 +359,7 @@ export class ConnectionTransport {
|
|
|
347
359
|
throw connectError("pending_mutation_unresolved", "A previous write still has an unknown outcome. Retry that exact write before making another change.");
|
|
348
360
|
}
|
|
349
361
|
requestId = pending.requestId;
|
|
350
|
-
resumingMutation =
|
|
362
|
+
resumingMutation = !knownRejectedMutationRetry;
|
|
351
363
|
}
|
|
352
364
|
pendingMutation = true;
|
|
353
365
|
}
|
|
@@ -429,7 +441,8 @@ export class ConnectionTransport {
|
|
|
429
441
|
body: JSON.stringify(encryptedRequest),
|
|
430
442
|
signal: options.signal
|
|
431
443
|
}));
|
|
432
|
-
if (!directFallbackStatus(response.status)
|
|
444
|
+
if (!directFallbackStatus(response.status)
|
|
445
|
+
|| await isExplicitConnectorBusyResponse(response)) {
|
|
433
446
|
if (response.ok) {
|
|
434
447
|
this.markDirectAvailable();
|
|
435
448
|
this.setRoute("direct");
|
|
@@ -528,20 +541,9 @@ export class ConnectionTransport {
|
|
|
528
541
|
};
|
|
529
542
|
}
|
|
530
543
|
directCapable(token) {
|
|
531
|
-
|
|
532
|
-
return false;
|
|
533
|
-
if (this.directAccessMode === "disabled")
|
|
534
|
-
return false;
|
|
535
|
-
if (typeof location !== "undefined"
|
|
536
|
-
&& token.applicationOrigin
|
|
537
|
-
&& token.applicationOrigin !== location.origin)
|
|
538
|
-
return false;
|
|
539
|
-
return true;
|
|
540
|
-
}
|
|
541
|
-
hasResumableMutationTransport() {
|
|
542
|
-
const token = this.currentToken();
|
|
543
|
-
return Boolean(token);
|
|
544
|
+
return tokenSupportsDirectAccess(token, this.directAccessMode);
|
|
544
545
|
}
|
|
546
|
+
hasResumableMutationTransport() { return this.currentToken() !== null; }
|
|
545
547
|
directEligible(token) {
|
|
546
548
|
return token !== null
|
|
547
549
|
&& this.directCapable(token)
|
|
@@ -569,23 +571,9 @@ export class ConnectionTransport {
|
|
|
569
571
|
}
|
|
570
572
|
async probeDirectAccess(signal) {
|
|
571
573
|
this.setDirectStatus("checking");
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
cache: "no-store",
|
|
576
|
-
signal
|
|
577
|
-
}));
|
|
578
|
-
const body = await decodeJsonResponse(response, "invalid_operation_response", "The connector returned an invalid readiness response.").catch(() => null);
|
|
579
|
-
if (response.ok
|
|
580
|
-
&& body?.service === "mdbase-connect"
|
|
581
|
-
&& body?.loopback_protocol_version === 1
|
|
582
|
-
&& body?.operation_transport_protocol_version === OPERATION_TRANSPORT_PROTOCOL_VERSION) {
|
|
583
|
-
this.markDirectAvailable();
|
|
584
|
-
return "available";
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
catch {
|
|
588
|
-
// Permission denial, unsupported mixed content, and an absent connector all reject fetch.
|
|
574
|
+
if (await probeLoopbackAccess(this.loopbackUrl, OPERATION_TRANSPORT_PROTOCOL_VERSION, signal)) {
|
|
575
|
+
this.markDirectAvailable();
|
|
576
|
+
return "available";
|
|
589
577
|
}
|
|
590
578
|
if ((await localNetworkPermission()) === "denied")
|
|
591
579
|
return this.setDirectStatus("denied");
|
|
@@ -615,9 +603,7 @@ export class ConnectionTransport {
|
|
|
615
603
|
this.emitConnection();
|
|
616
604
|
}
|
|
617
605
|
}
|
|
618
|
-
emitConnection() {
|
|
619
|
-
this.onChange();
|
|
620
|
-
}
|
|
606
|
+
emitConnection() { this.onChange(); }
|
|
621
607
|
invalidateRejectedAuthorization(rejected) {
|
|
622
608
|
const current = parseStored(this.storage.getItem(this.internals.tokenKey(this.collectionId)));
|
|
623
609
|
if (!current || !sameAuthorization(current, rejected))
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { StoredToken } from "./internal-types.js";
|
|
2
|
+
export declare function tokenSupportsDirectAccess(token: StoredToken | null, mode: "auto" | "disabled"): boolean;
|
|
3
|
+
export declare function probeLoopbackAccess(loopbackUrl: string, expectedOperationProtocol: number, signal?: AbortSignal): Promise<boolean>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { loopbackRequest } from "./operation-helpers.js";
|
|
2
|
+
import { decodeJsonResponse } from "./runtime-utils.js";
|
|
3
|
+
export function tokenSupportsDirectAccess(token, mode) {
|
|
4
|
+
if (!token || token.authority || !token.encryption || !token.grantId || !token.keyHandle) {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
if (mode === "disabled")
|
|
8
|
+
return false;
|
|
9
|
+
return typeof location === "undefined"
|
|
10
|
+
|| !token.applicationOrigin
|
|
11
|
+
|| token.applicationOrigin === location.origin;
|
|
12
|
+
}
|
|
13
|
+
export async function probeLoopbackAccess(loopbackUrl, expectedOperationProtocol, signal) {
|
|
14
|
+
try {
|
|
15
|
+
const response = await fetch(`${loopbackUrl}/v1/ready`, loopbackRequest({
|
|
16
|
+
method: "GET",
|
|
17
|
+
cache: "no-store",
|
|
18
|
+
signal
|
|
19
|
+
}));
|
|
20
|
+
const body = await decodeJsonResponse(response, "invalid_operation_response", "The connector returned an invalid readiness response.").catch(() => null);
|
|
21
|
+
return response.ok
|
|
22
|
+
&& body?.service === "mdbase-connect"
|
|
23
|
+
&& body?.loopback_protocol_version === 1
|
|
24
|
+
&& body?.operation_transport_protocol_version === expectedOperationProtocol;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { MdbaseConnectError, connectError } from "./errors.js";
|
|
2
2
|
import { IncrementalSha256 } from "./file-sha256.js";
|
|
3
|
+
import { MAX_TRANSIENT_CHUNK_ATTEMPTS, waitForTransientChunkRetry } from "./transient-retry.js";
|
|
3
4
|
const HASH_CHUNK_BYTES = 1024 * 1024;
|
|
4
5
|
const DEFAULT_CONCURRENCY = 4;
|
|
5
6
|
const MAX_CONCURRENCY = 8;
|
|
@@ -144,14 +145,17 @@ export function chunkLength(totalSize, partSize, index) {
|
|
|
144
145
|
return Math.min(partSize, Math.max(0, totalSize - index * partSize));
|
|
145
146
|
}
|
|
146
147
|
export async function retryChunk(work, signal) {
|
|
147
|
-
for (let attempt = 1; attempt <=
|
|
148
|
+
for (let attempt = 1; attempt <= MAX_TRANSIENT_CHUNK_ATTEMPTS; attempt += 1) {
|
|
148
149
|
throwIfAborted(signal);
|
|
149
150
|
try {
|
|
150
151
|
return await work();
|
|
151
152
|
}
|
|
152
153
|
catch (error) {
|
|
153
|
-
if (signal?.aborted
|
|
154
|
+
if (signal?.aborted
|
|
155
|
+
|| attempt === MAX_TRANSIENT_CHUNK_ATTEMPTS
|
|
156
|
+
|| (error instanceof MdbaseConnectError && !error.retryable))
|
|
154
157
|
throw error;
|
|
158
|
+
await waitForTransientChunkRetry(attempt, signal);
|
|
155
159
|
}
|
|
156
160
|
}
|
|
157
161
|
throw new Error("Unreachable file chunk retry state.");
|
|
@@ -4,6 +4,7 @@ import { MdbaseConnectError, connectError, serverConnectError } from "./errors.j
|
|
|
4
4
|
import { GrantFileTransferCipher } from "./file-crypto.js";
|
|
5
5
|
import { directFallbackStatus, loopbackRequest } from "./operation-helpers.js";
|
|
6
6
|
import { apiError, decodeJsonResponse } from "./runtime-utils.js";
|
|
7
|
+
import { isConnectorBusy, isExplicitConnectorBusyResponse, MAX_CONNECTOR_BUSY_RETRIES, waitForConnectorAvailability } from "./transient-retry.js";
|
|
7
8
|
/** Keeps grant-bound file crypto and loopback framing out of the operation transport. */
|
|
8
9
|
export class LocalFileTransport {
|
|
9
10
|
options;
|
|
@@ -11,9 +12,9 @@ export class LocalFileTransport {
|
|
|
11
12
|
this.options = options;
|
|
12
13
|
}
|
|
13
14
|
async control(token, method, path, input, signal) {
|
|
14
|
-
return this.controlAttempt(token, method, path, input, signal, false);
|
|
15
|
+
return this.controlAttempt(token, method, path, input, signal, false, 0);
|
|
15
16
|
}
|
|
16
|
-
async controlAttempt(token, method, path, input, signal, freshReadRetried) {
|
|
17
|
+
async controlAttempt(token, method, path, input, signal, freshReadRetried, connectorBusyRetries) {
|
|
17
18
|
requireEncryption(token);
|
|
18
19
|
const controlInput = localFileControlInput(method, path, input);
|
|
19
20
|
let encryptedRequest = await this.encryptControl(token, controlInput);
|
|
@@ -33,10 +34,11 @@ export class LocalFileTransport {
|
|
|
33
34
|
throw error;
|
|
34
35
|
this.options.onDirectUnavailable();
|
|
35
36
|
}
|
|
36
|
-
if (response && !directFallbackStatus(response.status)
|
|
37
|
+
if (response && (!directFallbackStatus(response.status)
|
|
38
|
+
|| await isExplicitConnectorBusyResponse(response))) {
|
|
37
39
|
if (response.ok)
|
|
38
40
|
this.options.onDirectAvailable();
|
|
39
|
-
return this.decryptControlResponse(token, method, path, input, signal, encryptedRequest, response, freshReadRetried);
|
|
41
|
+
return this.decryptControlResponse(token, method, path, input, signal, encryptedRequest, response, freshReadRetried, connectorBusyRetries);
|
|
40
42
|
}
|
|
41
43
|
if (response)
|
|
42
44
|
this.options.onDirectUnavailable();
|
|
@@ -57,16 +59,21 @@ export class LocalFileTransport {
|
|
|
57
59
|
}
|
|
58
60
|
if (response.ok)
|
|
59
61
|
this.options.onRelayAvailable();
|
|
60
|
-
return this.decryptControlResponse(token, method, path, input, signal, encryptedRequest, response, freshReadRetried);
|
|
62
|
+
return this.decryptControlResponse(token, method, path, input, signal, encryptedRequest, response, freshReadRetried, connectorBusyRetries);
|
|
61
63
|
}
|
|
62
|
-
async decryptControlResponse(token, method, path, input, signal, encryptedRequest, response, freshReadRetried) {
|
|
64
|
+
async decryptControlResponse(token, method, path, input, signal, encryptedRequest, response, freshReadRetried, connectorBusyRetries) {
|
|
63
65
|
const body = await decodeJsonResponse(response, "invalid_operation_response", "The collection authority returned an invalid file control response.");
|
|
64
66
|
if (!response.ok) {
|
|
65
67
|
const error = apiError(body, "operation_failed", "Collection file request failed.", response.status);
|
|
68
|
+
if (isConnectorBusy(error)
|
|
69
|
+
&& connectorBusyRetries < MAX_CONNECTOR_BUSY_RETRIES) {
|
|
70
|
+
await waitForConnectorAvailability(connectorBusyRetries, signal);
|
|
71
|
+
return this.controlAttempt(token, method, path, input, signal, freshReadRetried, connectorBusyRetries + 1);
|
|
72
|
+
}
|
|
66
73
|
if (error.code === "fresh_request_required"
|
|
67
74
|
&& method === "GET"
|
|
68
75
|
&& !freshReadRetried) {
|
|
69
|
-
return this.controlAttempt(token, method, path, input, signal, true);
|
|
76
|
+
return this.controlAttempt(token, method, path, input, signal, true, connectorBusyRetries);
|
|
70
77
|
}
|
|
71
78
|
throw error;
|
|
72
79
|
}
|
|
@@ -79,10 +86,17 @@ export class LocalFileTransport {
|
|
|
79
86
|
&& decrypted.problem.code === "fresh_request_required"
|
|
80
87
|
&& method === "GET"
|
|
81
88
|
&& !freshReadRetried) {
|
|
82
|
-
return this.controlAttempt(token, method, path, input, signal, true);
|
|
89
|
+
return this.controlAttempt(token, method, path, input, signal, true, connectorBusyRetries);
|
|
90
|
+
}
|
|
91
|
+
if (!decrypted.ok) {
|
|
92
|
+
const error = encryptedProblem(decrypted.problem);
|
|
93
|
+
if (isConnectorBusy(error)
|
|
94
|
+
&& connectorBusyRetries < MAX_CONNECTOR_BUSY_RETRIES) {
|
|
95
|
+
await waitForConnectorAvailability(connectorBusyRetries, signal);
|
|
96
|
+
return this.controlAttempt(token, method, path, input, signal, freshReadRetried, connectorBusyRetries + 1);
|
|
97
|
+
}
|
|
98
|
+
throw error;
|
|
83
99
|
}
|
|
84
|
-
if (!decrypted.ok)
|
|
85
|
-
throwEncryptedProblem(decrypted.problem);
|
|
86
100
|
return decrypted.result;
|
|
87
101
|
}
|
|
88
102
|
async uploadChunk(session, chunkIndex, bytes, signal) {
|
|
@@ -99,7 +113,8 @@ export class LocalFileTransport {
|
|
|
99
113
|
throw error;
|
|
100
114
|
this.options.onDirectUnavailable();
|
|
101
115
|
}
|
|
102
|
-
if (response && !directFallbackStatus(response.status)
|
|
116
|
+
if (response && (!directFallbackStatus(response.status)
|
|
117
|
+
|| await isExplicitConnectorBusyResponse(response))) {
|
|
103
118
|
if (response.ok)
|
|
104
119
|
this.options.onDirectAvailable();
|
|
105
120
|
return requireSuccessfulChunkResponse(response, "Encrypted file chunk upload failed.");
|
|
@@ -132,7 +147,8 @@ export class LocalFileTransport {
|
|
|
132
147
|
throw error;
|
|
133
148
|
this.options.onDirectUnavailable();
|
|
134
149
|
}
|
|
135
|
-
if (response && !directFallbackStatus(response.status)
|
|
150
|
+
if (response && (!directFallbackStatus(response.status)
|
|
151
|
+
|| await isExplicitConnectorBusyResponse(response))) {
|
|
136
152
|
if (response.ok)
|
|
137
153
|
this.options.onDirectAvailable();
|
|
138
154
|
return this.decryptDownloadResponse(token, session, chunkIndex, response);
|
|
@@ -313,8 +329,8 @@ function validateDownloadFrame(session, chunkIndex, encoded) {
|
|
|
313
329
|
throw connectError("invalid_operation_response", "The connector returned a file frame for a different chunk.");
|
|
314
330
|
}
|
|
315
331
|
}
|
|
316
|
-
function
|
|
317
|
-
|
|
332
|
+
function encryptedProblem(problem) {
|
|
333
|
+
return serverConnectError(problem.code === "unknown" ? problem.server_code : problem.code, problem.message, {
|
|
318
334
|
details: problem.details,
|
|
319
335
|
operationOutcome: problem.operation_outcome ?? "rejected",
|
|
320
336
|
traceId: problem.trace_id
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { MdbaseConnectError } from "./errors.js";
|
|
2
|
+
import type { OperationAttempt, PendingMutation } from "./internal-types.js";
|
|
3
|
+
export declare const MAX_CONNECTOR_BUSY_RETRIES = 8;
|
|
4
|
+
export declare const MAX_TRANSIENT_CHUNK_ATTEMPTS = 10;
|
|
5
|
+
export declare function isConnectorBusy(error: unknown): error is MdbaseConnectError;
|
|
6
|
+
export declare function retryExplicitConnectorBusy<Result>(options: {
|
|
7
|
+
error: unknown;
|
|
8
|
+
attempt: OperationAttempt;
|
|
9
|
+
completedRetries: number;
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
knownRejectedMutationRetry: boolean;
|
|
12
|
+
clearPending(requestId: string): void;
|
|
13
|
+
retry(pending: PendingMutation | undefined, knownRejected: boolean): Promise<Result>;
|
|
14
|
+
}): Promise<{
|
|
15
|
+
retried: false;
|
|
16
|
+
} | {
|
|
17
|
+
retried: true;
|
|
18
|
+
result: Result;
|
|
19
|
+
}>;
|
|
20
|
+
export declare function isExplicitConnectorBusyResponse(response: Response): Promise<boolean>;
|
|
21
|
+
export declare function waitForConnectorAvailability(retryIndex: number, signal?: AbortSignal): Promise<void>;
|
|
22
|
+
export declare function waitForTransientChunkRetry(completedAttempt: number, signal?: AbortSignal): Promise<void>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { abortableDelay } from "./async.js";
|
|
2
|
+
import { MdbaseConnectError, connectError } from "./errors.js";
|
|
3
|
+
export const MAX_CONNECTOR_BUSY_RETRIES = 8;
|
|
4
|
+
export const MAX_TRANSIENT_CHUNK_ATTEMPTS = 10;
|
|
5
|
+
export function isConnectorBusy(error) {
|
|
6
|
+
return error instanceof MdbaseConnectError && error.code === "connector_busy";
|
|
7
|
+
}
|
|
8
|
+
export async function retryExplicitConnectorBusy(options) {
|
|
9
|
+
if (!isConnectorBusy(options.error)
|
|
10
|
+
|| options.completedRetries >= MAX_CONNECTOR_BUSY_RETRIES) {
|
|
11
|
+
return { retried: false };
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
await waitForConnectorAvailability(options.completedRetries, options.signal);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (options.attempt.pendingMutation
|
|
18
|
+
&& (options.knownRejectedMutationRetry || !options.attempt.resumingMutation)) {
|
|
19
|
+
options.clearPending(options.attempt.requestId);
|
|
20
|
+
}
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
retried: true,
|
|
25
|
+
result: await options.retry(options.attempt.pendingMutationRecord, options.knownRejectedMutationRetry
|
|
26
|
+
|| (options.attempt.pendingMutation === true && !options.attempt.resumingMutation))
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export async function isExplicitConnectorBusyResponse(response) {
|
|
30
|
+
if (response.status !== 502 && response.status !== 503)
|
|
31
|
+
return false;
|
|
32
|
+
const body = await response.clone().json().catch(() => null);
|
|
33
|
+
return body?.error === "connector_busy"
|
|
34
|
+
|| (typeof body?.error === "object"
|
|
35
|
+
&& body.error?.code === "connector_busy");
|
|
36
|
+
}
|
|
37
|
+
export function waitForConnectorAvailability(retryIndex, signal) {
|
|
38
|
+
return retryDelay(jitteredBackoff(50, 800, retryIndex), signal);
|
|
39
|
+
}
|
|
40
|
+
export function waitForTransientChunkRetry(completedAttempt, signal) {
|
|
41
|
+
return retryDelay(jitteredBackoff(100, 1_000, completedAttempt - 1), signal);
|
|
42
|
+
}
|
|
43
|
+
async function retryDelay(milliseconds, signal) {
|
|
44
|
+
try {
|
|
45
|
+
await abortableDelay(milliseconds, signal);
|
|
46
|
+
}
|
|
47
|
+
catch (cause) {
|
|
48
|
+
if (signal?.aborted) {
|
|
49
|
+
if (signal.reason instanceof MdbaseConnectError)
|
|
50
|
+
throw signal.reason;
|
|
51
|
+
throw connectError("operation_cancelled", "The operation was cancelled while waiting for connector capacity.", { operationOutcome: "not_sent", cause });
|
|
52
|
+
}
|
|
53
|
+
throw cause;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function jitteredBackoff(base, cap, exponent) {
|
|
57
|
+
const bounded = Math.min(cap, base * (2 ** Math.max(0, exponent)));
|
|
58
|
+
return Math.max(1, Math.round(bounded * (0.75 + Math.random() * 0.5)));
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mdbase-dev/connect",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.61",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"README.md"
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@mdbase-dev/connect-protocol": "0.1.0-beta.
|
|
36
|
+
"@mdbase-dev/connect-protocol": "0.1.0-beta.61"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"esbuild": "^0.28.1",
|