Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions apps/array/src/main/services/connectivity/service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { net } from "electron";
import { injectable, postConstruct } from "inversify";
import { getBackoffDelay } from "../../../shared/utils/backoff.js";
import { logger } from "../../lib/logger.js";
import { TypedEventEmitter } from "../../lib/typed-event-emitter.js";
import {
Expand All @@ -19,7 +20,7 @@ const ONLINE_POLL_INTERVAL_MS = 3_000;
export class ConnectivityService extends TypedEventEmitter<ConnectivityEvents> {
private isOnline = false;
private pollTimeoutId: ReturnType<typeof setTimeout> | null = null;
private currentPollInterval = MIN_POLL_INTERVAL_MS;
private offlinePollAttempt = 0;

@postConstruct()
init(): void {
Expand All @@ -45,7 +46,7 @@ export class ConnectivityService extends TypedEventEmitter<ConnectivityEvents> {
log.info("Connectivity status changed", { isOnline: online });
this.emit(ConnectivityEvent.StatusChange, { isOnline: online });

this.currentPollInterval = MIN_POLL_INTERVAL_MS;
this.offlinePollAttempt = 0;
}

private async checkConnectivity(): Promise<void> {
Expand Down Expand Up @@ -73,7 +74,7 @@ export class ConnectivityService extends TypedEventEmitter<ConnectivityEvents> {
private startPolling(): void {
if (this.pollTimeoutId) return;

this.currentPollInterval = MIN_POLL_INTERVAL_MS;
this.offlinePollAttempt = 0;
this.schedulePoll();
}

Expand All @@ -82,7 +83,11 @@ export class ConnectivityService extends TypedEventEmitter<ConnectivityEvents> {
// when offline: poll more frequently with backoff to detect recovery
const interval = this.isOnline
? ONLINE_POLL_INTERVAL_MS
: this.currentPollInterval;
: getBackoffDelay(this.offlinePollAttempt, {
initialDelayMs: MIN_POLL_INTERVAL_MS,
maxDelayMs: MAX_POLL_INTERVAL_MS,
multiplier: 1.5,
});

this.pollTimeoutId = setTimeout(async () => {
this.pollTimeoutId = null;
Expand All @@ -91,10 +96,7 @@ export class ConnectivityService extends TypedEventEmitter<ConnectivityEvents> {
await this.checkConnectivity();

if (!this.isOnline && wasOffline) {
this.currentPollInterval = Math.min(
this.currentPollInterval * 1.5,
MAX_POLL_INTERVAL_MS,
);
this.offlinePollAttempt++;
}

this.schedulePoll();
Expand Down
8 changes: 5 additions & 3 deletions apps/array/src/renderer/features/auth/stores/authStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { logger } from "@renderer/lib/logger";
import { queryClient } from "@renderer/lib/queryClient";
import { trpcVanilla } from "@renderer/trpc/client";
import type { CloudRegion } from "@shared/types/oauth";
import { sleepWithBackoff } from "@shared/utils/backoff";
import { useNavigationStore } from "@stores/navigationStore";
import { create } from "zustand";
import { persist, subscribeWithSelector } from "zustand/middleware";
Expand Down Expand Up @@ -176,11 +177,12 @@ export const useAuthStore = create<AuthState>()(
for (let attempt = 0; attempt < REFRESH_MAX_RETRIES; attempt++) {
try {
if (attempt > 0) {
const delay = REFRESH_INITIAL_DELAY_MS * 2 ** (attempt - 1);
log.debug(
`Retrying token refresh (attempt ${attempt + 1}/${REFRESH_MAX_RETRIES}) after ${delay}ms`,
`Retrying token refresh (attempt ${attempt + 1}/${REFRESH_MAX_RETRIES})`,
);
await new Promise((resolve) => setTimeout(resolve, delay));
await sleepWithBackoff(attempt - 1, {
initialDelayMs: REFRESH_INITIAL_DELAY_MS,
});
}

const result = await trpcVanilla.oauth.refreshToken.mutate({
Expand Down
31 changes: 31 additions & 0 deletions apps/array/src/shared/utils/backoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export interface BackoffOptions {
initialDelayMs: number;
maxDelayMs?: number;
multiplier?: number;
}

/**
* Calculate delay for exponential backoff
* @param attempt - Zero-indexed attempt number (0 = first retry)
* @param options - Backoff configuration
* @returns Delay in milliseconds
*/
export function getBackoffDelay(
attempt: number,
options: BackoffOptions,
): number {
const { initialDelayMs, maxDelayMs, multiplier = 2 } = options;
const delay = initialDelayMs * multiplier ** attempt;
return maxDelayMs ? Math.min(delay, maxDelayMs) : delay;
}

/**
* Sleep with exponential backoff delay
*/
export function sleepWithBackoff(
attempt: number,
options: BackoffOptions,
): Promise<void> {
const delay = getBackoffDelay(attempt, options);
return new Promise((resolve) => setTimeout(resolve, delay));
}