Skip to content

Commit aa00b17

Browse files
committed
revert: "revert: "feat(container-loader): add optional connection retry timeout" (#25814)"
This reverts commit 7aae2a7. Restores the original "feat(container-loader): add optional connection retry timeout" commit that was inaccessible to customers.
1 parent 3b18f70 commit aa00b17

File tree

3 files changed

+184
-0
lines changed

3 files changed

+184
-0
lines changed

packages/loader/container-loader/src/connectionManager.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,12 @@ export class ConnectionManager implements IConnectionManager {
186186

187187
private connectFirstConnection = true;
188188

189+
/**
190+
* Tracks the initial connection start time for the entire lifetime of the class.
191+
* This is used only for retryConnectionTimeoutMs checks and is never reset.
192+
*/
193+
private initialConnectionStartTime: number | undefined;
194+
189195
private _connectionVerboseProps: Record<string, string | number> = {};
190196

191197
private _connectionProps: ITelemetryBaseProperties = {};
@@ -344,6 +350,7 @@ export class ConnectionManager implements IConnectionManager {
344350
reconnectAllowed: boolean,
345351
private readonly logger: ITelemetryLoggerExt,
346352
private readonly props: IConnectionManagerFactoryArgs,
353+
private readonly retryConnectionTimeoutMs?: number,
347354
) {
348355
this.clientDetails = this.client.details;
349356
this.defaultReconnectionMode = this.client.mode;
@@ -522,6 +529,12 @@ export class ConnectionManager implements IConnectionManager {
522529
let delayMs = InitialReconnectDelayInMs;
523530
let connectRepeatCount = 0;
524531
const connectStartTime = performanceNow();
532+
533+
// Set the initial connection start time only once for the entire lifetime of the class
534+
if (this.initialConnectionStartTime === undefined) {
535+
this.initialConnectionStartTime = connectStartTime;
536+
}
537+
525538
let lastError: unknown;
526539

527540
const abortController = new AbortController();
@@ -642,6 +655,31 @@ export class ConnectionManager implements IConnectionManager {
642655
this.props.reconnectionDelayHandler(delayMs, origError);
643656
}
644657

658+
// Check if the calculated delay would exceed the remaining timeout with a conservative buffer
659+
if (this.retryConnectionTimeoutMs !== undefined) {
660+
const elapsedTime = performanceNow() - this.initialConnectionStartTime;
661+
const remainingTime = this.retryConnectionTimeoutMs - elapsedTime;
662+
const connectionAttemptDuration = performanceNow() - connectStartTime;
663+
664+
// Use a 75% buffer to be conservative about timeout usage
665+
const timeoutBufferThreshold = remainingTime * 0.75;
666+
// Estimate the next attempt time as the delay plus the time it took to connect
667+
const estimatedNextAttemptTime = delayMs + connectionAttemptDuration;
668+
669+
if (estimatedNextAttemptTime >= timeoutBufferThreshold) {
670+
this.logger.sendTelemetryEvent({
671+
eventName: "RetryDelayExceedsConnectionTimeout",
672+
attempts: connectRepeatCount,
673+
duration: formatTick(elapsedTime),
674+
calculatedDelayMs: delayMs,
675+
remainingTimeMs: remainingTime,
676+
timeoutMs: this.retryConnectionTimeoutMs,
677+
});
678+
// Throw the error immediately since the estimated time for delay + next connection attempt would exceed our conservative timeout buffer
679+
throw normalizeError(origError, { props: fatalConnectErrorProp });
680+
}
681+
}
682+
645683
await new Promise<void>((resolve) => {
646684
setTimeout(resolve, delayMs);
647685
});

packages/loader/container-loader/src/container.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,16 @@ export interface IContainerCreateProps {
248248
* protocol implementation for handling the quorum and/or the audience.
249249
*/
250250
readonly protocolHandlerBuilder?: ProtocolHandlerBuilder;
251+
252+
/**
253+
* Optional property for specifying a timeout for retry connection loop.
254+
*
255+
* If provided, container will use this value as the maximum time to wait
256+
* for a successful connection before giving up throwing the most recent error.
257+
*
258+
* If not provided, default behavior will be to retry until non-retryable error occurs.
259+
*/
260+
readonly retryConnectionTimeoutMs?: number;
251261
}
252262

253263
/**
@@ -617,6 +627,7 @@ export class Container
617627
private attachmentData: AttachmentData = { state: AttachState.Detached };
618628
private readonly serializedStateManager: SerializedStateManager;
619629
private readonly _containerId: string;
630+
private readonly _retryConnectionTimeoutMs: number | undefined;
620631

621632
private lastVisible: number | undefined;
622633
private readonly visibilityEventHandler: (() => void) | undefined;
@@ -799,6 +810,7 @@ export class Container
799810
scope,
800811
subLogger,
801812
protocolHandlerBuilder,
813+
retryConnectionTimeoutMs,
802814
} = createProps;
803815

804816
// Validate that the Driver is compatible with this Loader.
@@ -814,6 +826,7 @@ export class Container
814826
const pendingLocalState = loadProps?.pendingLocalState;
815827

816828
this._canReconnect = canReconnect ?? true;
829+
this._retryConnectionTimeoutMs = retryConnectionTimeoutMs;
817830
this.clientDetailsOverride = clientDetailsOverride;
818831
this.urlResolver = urlResolver;
819832
this.serviceFactory = documentServiceFactory;
@@ -2054,6 +2067,7 @@ export class Container
20542067
this._canReconnect,
20552068
createChildLogger({ logger: this.subLogger, namespace: "ConnectionManager" }),
20562069
props,
2070+
this._retryConnectionTimeoutMs,
20572071
),
20582072
);
20592073

packages/loader/container-loader/src/test/connectionManager.spec.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,4 +386,136 @@ describe("connectionManager", () => {
386386
});
387387
});
388388
});
389+
390+
it("Connection retry timeout - throws error when delay exceeds remaining timeout", async () => {
391+
// Arrange
392+
const timeoutMs = 5000; // 5 second timeout
393+
const connectionManager = new ConnectionManager(
394+
() => mockDocumentService,
395+
() => false,
396+
client as IClient,
397+
true /* reconnectAllowed */,
398+
mockLogger.toTelemetryLogger(),
399+
props,
400+
timeoutMs, // Set the retry connection timeout
401+
);
402+
403+
// Mock connectToDeltaStream to throw retryable errors that cause delays
404+
const stubbedConnectToDeltaStream = stub(mockDocumentService, "connectToDeltaStream");
405+
stubbedConnectToDeltaStream.throws(
406+
new RetryableError("Throttling error", DriverErrorTypes.throttlingError, {
407+
retryAfterSeconds: 3, // 3 second delay from error
408+
driverVersion: pkgVersion,
409+
}),
410+
);
411+
412+
// Act
413+
connectionManager.connect({ text: "test:retry timeout" }, "write");
414+
415+
// Advance time to trigger multiple retries and eventually hit the timeout
416+
await clock.tickAsync(6000); // This should trigger the timeout logic
417+
418+
// Verify the telemetry event was logged
419+
mockLogger.assertMatchAny([
420+
{
421+
eventName: "RetryDelayExceedsConnectionTimeout",
422+
attempts: 2,
423+
calculatedDelayMs: 6000, // The calculated delay that exceeded timeout
424+
remainingTimeMs: 2000, // Should be positive but less than calculatedDelayMs
425+
timeoutMs,
426+
},
427+
]);
428+
429+
// Verify that the container was closed due to the timeout
430+
assert(closed, "Connection manager should close when retry delay exceeds timeout");
431+
432+
stubbedConnectToDeltaStream.restore();
433+
});
434+
435+
it("Connection retry timeout - continues retrying when delay is within timeout", async () => {
436+
// Arrange
437+
const timeoutMs = 20000; // 20 second timeout (longer than test duration)
438+
const connectionManager = new ConnectionManager(
439+
() => mockDocumentService,
440+
() => false,
441+
client as IClient,
442+
true /* reconnectAllowed */,
443+
mockLogger.toTelemetryLogger(),
444+
props,
445+
timeoutMs, // Set the retry connection timeout
446+
);
447+
448+
// Mock connectToDeltaStream to throw retryable errors with short delays
449+
const stubbedConnectToDeltaStream = stub(mockDocumentService, "connectToDeltaStream");
450+
stubbedConnectToDeltaStream.throws(
451+
new RetryableError("Temporary error", DriverErrorTypes.genericError, {
452+
retryAfterSeconds: 0.1, // Very small delay to keep within timeout
453+
driverVersion: pkgVersion,
454+
}),
455+
);
456+
457+
// Act
458+
connectionManager.connect({ text: "test:retry within timeout" }, "write");
459+
460+
// Advance time for a few retries but not long enough to exceed timeout
461+
await clock.tickAsync(2000); // 2 seconds - should allow for multiple retries
462+
463+
// Assert - the connection should still be retrying and not have closed
464+
assert(!closed, "Connection manager should not close when retries are within timeout");
465+
assert(
466+
stubbedConnectToDeltaStream.callCount > 1,
467+
"Should have made multiple connection attempts",
468+
);
469+
470+
// Verify no timeout telemetry was logged
471+
assert(
472+
!mockLogger.matchEvents([{ eventName: "RetryDelayExceedsConnectionTimeout" }]),
473+
"Should not log timeout event when retries are within limit",
474+
);
475+
476+
stubbedConnectToDeltaStream.restore();
477+
});
478+
479+
it("Connection retry timeout - no timeout when retryConnectionTimeoutMs is undefined", async () => {
480+
// Arrange - Create connection manager without timeout
481+
const connectionManager = new ConnectionManager(
482+
() => mockDocumentService,
483+
() => false,
484+
client as IClient,
485+
true /* reconnectAllowed */,
486+
mockLogger.toTelemetryLogger(),
487+
props,
488+
undefined, // No retry timeout
489+
);
490+
491+
// Mock connectToDeltaStream to throw retryable errors
492+
const stubbedConnectToDeltaStream = stub(mockDocumentService, "connectToDeltaStream");
493+
stubbedConnectToDeltaStream.throws(
494+
new RetryableError("Delay error", DriverErrorTypes.genericError, {
495+
retryAfterSeconds: 1, // 1 second delay
496+
driverVersion: pkgVersion,
497+
}),
498+
);
499+
500+
// Act
501+
connectionManager.connect({ text: "test:no timeout limit" }, "write");
502+
503+
// Advance time for several retries
504+
await clock.tickAsync(10000); // 10 seconds - would exceed most reasonable timeouts
505+
506+
// Assert - the connection should still be retrying and not have closed
507+
assert(!closed, "Connection manager should not close when no timeout is set");
508+
assert(
509+
stubbedConnectToDeltaStream.callCount > 1,
510+
"Should have made multiple connection attempts",
511+
);
512+
513+
// Verify no timeout telemetry was logged
514+
assert(
515+
!mockLogger.matchEvents([{ eventName: "RetryDelayExceedsConnectionTimeout" }]),
516+
"Should not log timeout event when no timeout is configured",
517+
);
518+
519+
stubbedConnectToDeltaStream.restore();
520+
});
389521
});

0 commit comments

Comments
 (0)