feat: add background retry scheduler for federation password sync

Replace the give-up-after-3-retries path with a persistent background
scheduler: 10 retries every 30s (5 min), then 12 retries every 5 min
(1 hr). Active timers are keyed by origin so rapid password changes
cancel and replace the previous loop. All timers are cleared on logout
via clearPasswordSyncTimers() called from instanceStore.reset().
This commit is contained in:
Jannis Braun
2026-03-23 00:52:40 +01:00
parent 678e1bf358
commit 95cb9d4df2
2 changed files with 93 additions and 2 deletions
+6
View File
@@ -5,6 +5,10 @@ import { useAuthStore } from './authStore';
import { setApiForOriginResolver, setUserIdForOriginResolver, setOriginFromHostnameResolver, useSpaceStore } from './spaceStore'; import { setApiForOriginResolver, setUserIdForOriginResolver, setOriginFromHostnameResolver, useSpaceStore } from './spaceStore';
import { connectInstance, disconnectInstance, disconnectAllRemote } from '../hooks/useWebSocket'; import { connectInstance, disconnectInstance, disconnectAllRemote } from '../hooks/useWebSocket';
import { syncProfileToRemote } from '../utils/profileSync'; import { syncProfileToRemote } from '../utils/profileSync';
// Circular dependency: federationOps imports useInstanceStore, instanceStore imports this.
// Safe because both modules access each other lazily (at call time, not import time).
// clearPasswordSyncTimers itself does not reference useInstanceStore.
import { clearPasswordSyncTimers } from '../utils/federationOps';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
@@ -696,6 +700,8 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
// Tear down all remote WebSocket connections // Tear down all remote WebSocket connections
disconnectAllRemote(); disconnectAllRemote();
clearPasswordSyncTimers();
set({ instances: [], isLoading: false, error: null, _autoConnectDone: false, pendingSyncOrigins: [] }); set({ instances: [], isLoading: false, error: null, _autoConnectDone: false, pendingSyncOrigins: [] });
// Token cache preserved — scoped per user, survives logout for seamless reconnect // Token cache preserved — scoped per user, survives logout for seamless reconnect
}, },
+87 -2
View File
@@ -29,11 +29,88 @@ async function retryWithBackoff<T>(
throw lastError; throw lastError;
} }
// ─── Background retry state ─────────────────────────────────────────────
/** Active retry timers per origin — cleared on new password change or logout */
const activeRetryTimers = new Map<string, ReturnType<typeof setTimeout>>();
/** Cancel any active retry loop for the given origin */
function cancelRetryTimer(origin: string): void {
const timer = activeRetryTimers.get(origin);
if (timer) {
clearTimeout(timer);
activeRetryTimers.delete(origin);
}
}
/** Cancel all active retry loops (called on logout) */
export function clearPasswordSyncTimers(): void {
for (const timer of activeRetryTimers.values()) {
clearTimeout(timer);
}
activeRetryTimers.clear();
}
/**
* Schedule background retries for a failed password sync.
* Retry schedule: every 30s for 5 min, then every 5 min for 1 hour.
*/
function scheduleBackgroundRetry(
origin: string,
newPassword: string,
): void {
// Build the retry schedule: [delayMs, ...]
const schedule: number[] = [
...Array(10).fill(30_000), // 10 × 30s = 5 min
...Array(12).fill(300_000), // 12 × 5min = 60 min
];
let attempt = 0;
function tryNext(): void {
if (attempt >= schedule.length) {
// Exhausted — mark as pending and stop
useInstanceStore.getState().setPendingPasswordSync(origin, true);
activeRetryTimers.delete(origin);
return;
}
const delay = schedule[attempt]!;
attempt++;
const timer = setTimeout(async () => {
try {
// Look up current instance from store — the original reference may be
// stale (token refreshed, instance reconnected) after minutes/hours
const current = useInstanceStore.getState().instances.find(i => i.origin === origin);
if (!current || current.status !== 'connected') {
// Instance was removed or disconnected — stop retrying
activeRetryTimers.delete(origin);
return;
}
const response = await current.api.users.changePassword({ newPassword });
useInstanceStore.getState().updateInstanceToken(origin, response.token);
useInstanceStore.getState().setPendingPasswordSync(origin, false);
activeRetryTimers.delete(origin);
} catch {
// Still failing — schedule next attempt
tryNext();
}
}, delay);
activeRetryTimers.set(origin, timer);
}
tryNext();
}
// ─── Password change propagation ──────────────────────────────────────── // ─── Password change propagation ────────────────────────────────────────
/** /**
* Change password on all connected remote instances. * Change password on all connected remote instances.
* For federated users, only newPassword is needed (JWT auth is sufficient). * For federated users, only newPassword is needed (JWT auth is sufficient).
* Failed instances get background retry scheduling.
*/ */
export async function changePasswordOnRemotes(newPassword: string): Promise<FederationOpResult[]> { export async function changePasswordOnRemotes(newPassword: string): Promise<FederationOpResult[]> {
const { instances } = useInstanceStore.getState(); const { instances } = useInstanceStore.getState();
@@ -41,6 +118,11 @@ export async function changePasswordOnRemotes(newPassword: string): Promise<Fede
if (connected.length === 0) return []; if (connected.length === 0) return [];
// Cancel any existing retry loops for these origins (handles rapid password changes)
for (const inst of connected) {
cancelRetryTimer(inst.origin);
}
const results = await Promise.allSettled( const results = await Promise.allSettled(
connected.map(async (inst): Promise<FederationOpResult> => { connected.map(async (inst): Promise<FederationOpResult> => {
try { try {
@@ -52,11 +134,14 @@ export async function changePasswordOnRemotes(newPassword: string): Promise<Fede
// Update the cached token for this instance // Update the cached token for this instance
useInstanceStore.getState().updateInstanceToken(inst.origin, response.token); useInstanceStore.getState().updateInstanceToken(inst.origin, response.token);
useInstanceStore.getState().setPendingPasswordSync(inst.origin, false);
return { origin: inst.origin, success: true }; return { origin: inst.origin, success: true };
} catch (err) { } catch (err) {
// Mark as pending sync for later retry // Initial retries failed — start background retry scheduler.
useInstanceStore.getState().setPendingPasswordSync(inst.origin, true); // Pass origin (not the ConnectedInstance) so the retry loop looks up
// the current instance from the store at each attempt, avoiding stale references.
scheduleBackgroundRetry(inst.origin, newPassword);
return { return {
origin: inst.origin, origin: inst.origin,