mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-24 05:07:06 +08:00
feat(core): improve reconnect handling (#15458)
#### PR Dependency Tree * **PR #15458** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved realtime connection recovery with progressively longer retry delays, capped to prevent excessive waiting. * Connection retries now reset after successful reconnection and stop cleanly when no longer needed. * Reduced false error alerts by confirming repeated failures before notifying users. * Added cancellation handling when connection context changes or the service is closed. * **User Experience** * Realtime connection alerts now identify authentication, network, server, and timeout issues with localized messages. * Active alerts are automatically dismissed when the connection recovers. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -117,9 +117,12 @@ test('retry when connect failed', async () => {
|
|||||||
class TestConnection extends AutoReconnectConnection {
|
class TestConnection extends AutoReconnectConnection {
|
||||||
override retryDelay = 300;
|
override retryDelay = 300;
|
||||||
connectCount = 0;
|
connectCount = 0;
|
||||||
|
retryDelayFor(retryCount: number) {
|
||||||
|
return this.getRetryDelay(retryCount);
|
||||||
|
}
|
||||||
override async doConnect() {
|
override async doConnect() {
|
||||||
this.connectCount++;
|
this.connectCount++;
|
||||||
if (this.connectCount === 3) {
|
if (this.connectCount >= 3) {
|
||||||
return { hello: 'world' };
|
return { hello: 'world' };
|
||||||
}
|
}
|
||||||
throw new Error('not connected, count: ' + this.connectCount);
|
throw new Error('not connected, count: ' + this.connectCount);
|
||||||
@@ -127,9 +130,15 @@ test('retry when connect failed', async () => {
|
|||||||
override doDisconnect() {
|
override doDisconnect() {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
triggerError(error: Error) {
|
||||||
|
this.error = error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const connection = new TestConnection();
|
const connection = new TestConnection();
|
||||||
|
expect([0, 1, 2, 8].map(count => connection.retryDelayFor(count))).toEqual([
|
||||||
|
300, 600, 1200, 60000,
|
||||||
|
]);
|
||||||
connection.connect();
|
connection.connect();
|
||||||
|
|
||||||
await vitest.waitFor(() => {
|
await vitest.waitFor(() => {
|
||||||
@@ -149,6 +158,12 @@ test('retry when connect failed', async () => {
|
|||||||
expect(connection.status).toBe('connected');
|
expect(connection.status).toBe('connected');
|
||||||
expect(connection.error).toBeUndefined();
|
expect(connection.error).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
connection.triggerError(new Error('disconnected'));
|
||||||
|
await vitest.waitFor(() => {
|
||||||
|
expect(connection.connectCount).toBe(4);
|
||||||
|
expect(connection.status).toBe('connected');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('retry when error', async () => {
|
test('retry when error', async () => {
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ export abstract class AutoReconnectConnection<
|
|||||||
private _status: ConnectionStatus = 'idle';
|
private _status: ConnectionStatus = 'idle';
|
||||||
private _error: Error | undefined = undefined;
|
private _error: Error | undefined = undefined;
|
||||||
retryDelay = 3000;
|
retryDelay = 3000;
|
||||||
|
maxRetryDelay = 60000;
|
||||||
connectingTimeout = 15000;
|
connectingTimeout = 15000;
|
||||||
|
private retryCount = 0;
|
||||||
private refCount = 0;
|
private refCount = 0;
|
||||||
private connectingAbort?: AbortController;
|
private connectingAbort?: AbortController;
|
||||||
private reconnectingAbort?: AbortController;
|
private reconnectingAbort?: AbortController;
|
||||||
@@ -103,6 +105,7 @@ export abstract class AutoReconnectConnection<
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
if (!signal.aborted) {
|
if (!signal.aborted) {
|
||||||
this._inner = value;
|
this._inner = value;
|
||||||
|
this.retryCount = 0;
|
||||||
this.setStatus('connected');
|
this.setStatus('connected');
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
@@ -150,16 +153,21 @@ export abstract class AutoReconnectConnection<
|
|||||||
|
|
||||||
this.reconnectingAbort = new AbortController();
|
this.reconnectingAbort = new AbortController();
|
||||||
const signal = this.reconnectingAbort.signal;
|
const signal = this.reconnectingAbort.signal;
|
||||||
|
const retryDelay = this.getRetryDelay(this.retryCount++);
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (!signal.aborted) {
|
if (!signal.aborted) {
|
||||||
this.innerConnect();
|
this.innerConnect();
|
||||||
}
|
}
|
||||||
}, this.retryDelay);
|
}, retryDelay);
|
||||||
signal.addEventListener('abort', () => {
|
signal.addEventListener('abort', () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected getRetryDelay(retryCount: number) {
|
||||||
|
return Math.min(this.retryDelay * 2 ** retryCount, this.maxRetryDelay);
|
||||||
|
}
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
this.refCount++;
|
this.refCount++;
|
||||||
if (this.refCount === 1) {
|
if (this.refCount === 1) {
|
||||||
@@ -175,6 +183,7 @@ export abstract class AutoReconnectConnection<
|
|||||||
}
|
}
|
||||||
if (this.refCount === 0) {
|
if (this.refCount === 0) {
|
||||||
this.innerDisconnect();
|
this.innerDisconnect();
|
||||||
|
this.retryCount = 0;
|
||||||
this.setStatus('closed');
|
this.setStatus('closed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,13 +150,23 @@ export const WorkspaceSideEffects = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!realtimeConnectionError) return;
|
if (!realtimeConnectionError) return;
|
||||||
notify.warning(
|
const message = {
|
||||||
|
authentication:
|
||||||
|
t['com.affine.realtime.connection-error.authentication'](),
|
||||||
|
network: t['com.affine.realtime.connection-error.network'](),
|
||||||
|
server: t['com.affine.realtime.connection-error.server'](),
|
||||||
|
timeout: t['com.affine.realtime.connection-error.timeout'](),
|
||||||
|
}[realtimeConnectionError.type];
|
||||||
|
const id = notify.warning(
|
||||||
{
|
{
|
||||||
title: t['com.affine.realtime.connection-error.title'](),
|
title: t['com.affine.realtime.connection-error.title'](),
|
||||||
message: t['com.affine.realtime.connection-error.message'](),
|
message,
|
||||||
},
|
},
|
||||||
{ id: `realtime-connection-error:${realtimeConnectionError.endpoint}` }
|
{ id: `realtime-connection-error:${realtimeConnectionError.endpoint}` }
|
||||||
);
|
);
|
||||||
|
return () => {
|
||||||
|
notify.dismiss(id);
|
||||||
|
};
|
||||||
}, [realtimeConnectionError, t]);
|
}, [realtimeConnectionError, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -8,13 +8,62 @@ import type { NbstoreService } from '../../storage';
|
|||||||
import type { Server } from '../entities/server';
|
import type { Server } from '../entities/server';
|
||||||
import type { ServersService } from './servers';
|
import type { ServersService } from './servers';
|
||||||
|
|
||||||
|
const CONNECTION_FAILURE_THRESHOLD = 3;
|
||||||
|
const INITIAL_RETRY_DELAY = 3000;
|
||||||
|
const MAX_RETRY_DELAY = 60000;
|
||||||
|
|
||||||
|
function getConnectionErrorType(error: unknown) {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return 'server';
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = `${error.name} ${error.message}`.toLowerCase();
|
||||||
|
if (
|
||||||
|
detail.includes('auth') ||
|
||||||
|
detail.includes('forbidden') ||
|
||||||
|
detail.includes('unauthorized') ||
|
||||||
|
detail.includes('jwt')
|
||||||
|
) {
|
||||||
|
return 'authentication';
|
||||||
|
}
|
||||||
|
if (detail.includes('timeout') || detail.includes('timed out')) {
|
||||||
|
return 'timeout';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
detail.includes('network') ||
|
||||||
|
detail.includes('transport') ||
|
||||||
|
detail.includes('websocket') ||
|
||||||
|
detail.includes('xhr') ||
|
||||||
|
detail.includes('connect')
|
||||||
|
) {
|
||||||
|
return 'network';
|
||||||
|
}
|
||||||
|
return 'server';
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForRetry(delay: number, signal: AbortSignal) {
|
||||||
|
return new Promise<void>(resolve => {
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
signal.removeEventListener('abort', onAbort);
|
||||||
|
resolve();
|
||||||
|
}, delay);
|
||||||
|
signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@OnEvent(ApplicationStarted, service => service.onApplicationStarted)
|
@OnEvent(ApplicationStarted, service => service.onApplicationStarted)
|
||||||
export class RealtimeService extends Service {
|
export class RealtimeService extends Service {
|
||||||
readonly connectionError$ = new LiveData<{
|
readonly connectionError$ = new LiveData<{
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
error: unknown;
|
error: unknown;
|
||||||
|
type: ReturnType<typeof getConnectionErrorType>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
private contextGeneration = 0;
|
private contextGeneration = 0;
|
||||||
|
private probeAbort?: AbortController;
|
||||||
|
|
||||||
private readonly currentServer$ =
|
private readonly currentServer$ =
|
||||||
this.globalContextService.globalContext.serverId.$.selector(id =>
|
this.globalContextService.globalContext.serverId.$.selector(id =>
|
||||||
@@ -50,11 +99,17 @@ export class RealtimeService extends Service {
|
|||||||
|
|
||||||
const subscription = this.currentServer$.subscribe(context => {
|
const subscription = this.currentServer$.subscribe(context => {
|
||||||
const generation = ++this.contextGeneration;
|
const generation = ++this.contextGeneration;
|
||||||
this.configure(context, generation).catch(error => {
|
this.probeAbort?.abort();
|
||||||
|
const probeAbort = new AbortController();
|
||||||
|
this.probeAbort = probeAbort;
|
||||||
|
this.configure(context, generation, probeAbort.signal).catch(error => {
|
||||||
console.error('Failed to configure realtime context', error);
|
console.error('Failed to configure realtime context', error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
this.disposables.push(() => subscription.unsubscribe());
|
this.disposables.push(() => {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
this.probeAbort?.abort();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onApplicationStarted() {}
|
onApplicationStarted() {}
|
||||||
@@ -65,7 +120,8 @@ export class RealtimeService extends Service {
|
|||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
isSelfHosted: boolean;
|
isSelfHosted: boolean;
|
||||||
},
|
},
|
||||||
generation: number
|
generation: number,
|
||||||
|
signal: AbortSignal
|
||||||
) {
|
) {
|
||||||
await this.nbstoreService.realtime.configure(context);
|
await this.nbstoreService.realtime.configure(context);
|
||||||
if (generation !== this.contextGeneration) return;
|
if (generation !== this.contextGeneration) return;
|
||||||
@@ -74,19 +130,34 @@ export class RealtimeService extends Service {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
let failures = 0;
|
||||||
await this.nbstoreService.realtime.request(
|
let retryDelay = INITIAL_RETRY_DELAY;
|
||||||
'user.profile.get',
|
while (generation === this.contextGeneration && !signal.aborted) {
|
||||||
{},
|
try {
|
||||||
{ timeoutMs: 10_000 }
|
await this.nbstoreService.realtime.request(
|
||||||
);
|
'user.profile.get',
|
||||||
if (generation === this.contextGeneration) {
|
{},
|
||||||
this.connectionError$.next(null);
|
{ timeoutMs: 10_000, signal }
|
||||||
}
|
);
|
||||||
} catch (error) {
|
if (generation === this.contextGeneration && !signal.aborted) {
|
||||||
if (generation === this.contextGeneration) {
|
this.connectionError$.next(null);
|
||||||
this.connectionError$.next({ endpoint: context.endpoint, error });
|
}
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted || generation !== this.contextGeneration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
failures++;
|
||||||
|
if (failures >= CONNECTION_FAILURE_THRESHOLD) {
|
||||||
|
this.connectionError$.next({
|
||||||
|
endpoint: context.endpoint,
|
||||||
|
error,
|
||||||
|
type: getConnectionErrorType(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
await waitForRetry(retryDelay, signal);
|
||||||
|
retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9681,9 +9681,21 @@ export function useAFFiNEI18N(): {
|
|||||||
*/
|
*/
|
||||||
["com.affine.realtime.connection-error.title"](): string;
|
["com.affine.realtime.connection-error.title"](): string;
|
||||||
/**
|
/**
|
||||||
* `Check that your server proxy forwards /socket.io over WebSocket or HTTP polling.`
|
* `The real-time connection could not authenticate your session. Sign in again if the problem continues.`
|
||||||
*/
|
*/
|
||||||
["com.affine.realtime.connection-error.message"](): string;
|
["com.affine.realtime.connection-error.authentication"](): string;
|
||||||
|
/**
|
||||||
|
* `The server cannot be reached. Check your network connection and server proxy.`
|
||||||
|
*/
|
||||||
|
["com.affine.realtime.connection-error.network"](): string;
|
||||||
|
/**
|
||||||
|
* `The server rejected the real-time request. Try again later.`
|
||||||
|
*/
|
||||||
|
["com.affine.realtime.connection-error.server"](): string;
|
||||||
|
/**
|
||||||
|
* `The server did not respond in time. AFFiNE will keep trying to reconnect.`
|
||||||
|
*/
|
||||||
|
["com.affine.realtime.connection-error.timeout"](): string;
|
||||||
/**
|
/**
|
||||||
* `An internal error occurred.`
|
* `An internal error occurred.`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2307,7 +2307,6 @@
|
|||||||
"com.affine.settings.devices.sign-out-failed": "Gerät konnte nicht abgemeldet werden",
|
"com.affine.settings.devices.sign-out-failed": "Gerät konnte nicht abgemeldet werden",
|
||||||
"com.affine.settings.devices.sign-out-all-failed": "Geräte konnten nicht abgemeldet werden",
|
"com.affine.settings.devices.sign-out-all-failed": "Geräte konnten nicht abgemeldet werden",
|
||||||
"com.affine.realtime.connection-error.title": "Echtzeitverbindung fehlgeschlagen",
|
"com.affine.realtime.connection-error.title": "Echtzeitverbindung fehlgeschlagen",
|
||||||
"com.affine.realtime.connection-error.message": "Stelle sicher, dass dein Server-Proxy /socket.io über WebSocket oder HTTP-Polling weiterleitet.",
|
|
||||||
"error.INTERNAL_SERVER_ERROR": "Es ist ein interner Fehler aufgetreten.",
|
"error.INTERNAL_SERVER_ERROR": "Es ist ein interner Fehler aufgetreten.",
|
||||||
"error.NETWORK_ERROR": "Netzwerkfehler.",
|
"error.NETWORK_ERROR": "Netzwerkfehler.",
|
||||||
"error.TOO_MANY_REQUEST": "Zu viele Anfragen.",
|
"error.TOO_MANY_REQUEST": "Zu viele Anfragen.",
|
||||||
|
|||||||
@@ -2410,7 +2410,10 @@
|
|||||||
"com.affine.settings.devices.sign-out-failed": "Failed to sign out device",
|
"com.affine.settings.devices.sign-out-failed": "Failed to sign out device",
|
||||||
"com.affine.settings.devices.sign-out-all-failed": "Failed to sign out devices",
|
"com.affine.settings.devices.sign-out-all-failed": "Failed to sign out devices",
|
||||||
"com.affine.realtime.connection-error.title": "Real-time connection failed",
|
"com.affine.realtime.connection-error.title": "Real-time connection failed",
|
||||||
"com.affine.realtime.connection-error.message": "Check that your server proxy forwards /socket.io over WebSocket or HTTP polling.",
|
"com.affine.realtime.connection-error.authentication": "The real-time connection could not authenticate your session. Sign in again if the problem continues.",
|
||||||
|
"com.affine.realtime.connection-error.network": "The server cannot be reached. Check your network connection and server proxy.",
|
||||||
|
"com.affine.realtime.connection-error.server": "The server rejected the real-time request. Try again later.",
|
||||||
|
"com.affine.realtime.connection-error.timeout": "The server did not respond in time. AFFiNE will keep trying to reconnect.",
|
||||||
"error.INTERNAL_SERVER_ERROR": "An internal error occurred.",
|
"error.INTERNAL_SERVER_ERROR": "An internal error occurred.",
|
||||||
"error.NETWORK_ERROR": "Network error.",
|
"error.NETWORK_ERROR": "Network error.",
|
||||||
"error.TOO_MANY_REQUEST": "Too many requests.",
|
"error.TOO_MANY_REQUEST": "Too many requests.",
|
||||||
|
|||||||
@@ -2307,7 +2307,6 @@
|
|||||||
"error.INTERNAL_SERVER_ERROR": "发生内部错误。",
|
"error.INTERNAL_SERVER_ERROR": "发生内部错误。",
|
||||||
"error.NETWORK_ERROR": "网络错误。",
|
"error.NETWORK_ERROR": "网络错误。",
|
||||||
"com.affine.realtime.connection-error.title": "实时同步连接失败",
|
"com.affine.realtime.connection-error.title": "实时同步连接失败",
|
||||||
"com.affine.realtime.connection-error.message": "请检查服务器反向代理是否通过 WebSocket 或 HTTP polling 正确转发 /socket.io。",
|
|
||||||
"error.TOO_MANY_REQUEST": "请求过多。",
|
"error.TOO_MANY_REQUEST": "请求过多。",
|
||||||
"error.SSRF_BLOCKED_ERROR": "无效的 URL",
|
"error.SSRF_BLOCKED_ERROR": "无效的 URL",
|
||||||
"error.RESPONSE_TOO_LARGE_ERROR": "响应过大({{receivedBytes}} 字节),限制为 {{limitBytes}} 字节",
|
"error.RESPONSE_TOO_LARGE_ERROR": "响应过大({{receivedBytes}} 字节),限制为 {{limitBytes}} 字节",
|
||||||
|
|||||||
Reference in New Issue
Block a user