diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts b/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts index 4076eb8..57bd9b3 100644 --- a/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts @@ -22,13 +22,16 @@ describe('OfflineCoordinatorService', () => { }; const session = { initialize: vi.fn(async () => undefined), - activateSession: vi.fn(async (userId: number, _scopeIds: readonly number[], _authSubject: string | null, lease: { isCurrent(): boolean }) => { - order.push('activate-remote'); - if (!lease.isCurrent()) return false; - sessionState.userId = userId; - return true; - }), + activateSession: vi.fn( + async (userId: number, _scopeIds: readonly number[], _authSubject: string | null, lease: { isCurrent(): boolean }) => { + order.push('activate-remote'); + if (!lease.isCurrent()) return false; + sessionState.userId = userId; + return true; + }, + ), suspendRemoteSession: vi.fn(async () => void order.push('suspend-remote')), + revokeAccess: vi.fn(() => void order.push('revoke')), activateOfflineSession: vi.fn(async () => { order.push('activate-local'); return manifest; @@ -44,6 +47,7 @@ describe('OfflineCoordinatorService', () => { conflicts: signal([]), initialize: vi.fn(async () => undefined), resetSession: vi.fn(async () => void order.push('reset')), + revokeSession: vi.fn(() => void order.push('revoke-sync')), refreshSession: vi.fn(async () => void order.push('resume-remote')), refreshLocalSession: vi.fn(async () => void order.push('refresh-local')), discardAllPending: vi.fn(async () => undefined), @@ -119,6 +123,16 @@ describe('OfflineCoordinatorService', () => { expect(sessionState.userId).toBeNull(); }); + it('revokes runtime local access synchronously before queued durable cleanup', async () => { + const { coordinator, session, sync } = setup(); + + const clearing = coordinator.clearActiveSession(); + + expect(session.revokeAccess).toHaveBeenCalledOnce(); + expect(sync.revokeSession).toHaveBeenCalledOnce(); + await clearing; + }); + it('keeps a newer identity when an older activation completes late', async () => { const { coordinator, session, sessionState } = setup(); let releaseOld: (() => void) | undefined; diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.ts b/projects/kit/offline/src/lib/offline-coordinator.service.ts index 19cd574..92e68fc 100644 --- a/projects/kit/offline/src/lib/offline-coordinator.service.ts +++ b/projects/kit/offline/src/lib/offline-coordinator.service.ts @@ -59,10 +59,7 @@ export class OfflineCoordinatorService { /** * Activates a restored identity for local replica/outbox use without enabling transport sync. */ - activateOfflineSession( - authSubject?: string | null, - authLease?: OfflineSessionTransitionLease, - ): Promise { + activateOfflineSession(authSubject?: string | null, authLease?: OfflineSessionTransitionLease): Promise { const revision = ++this.#transitionRevision; const lease = this.#lease(revision, authLease); return this.#enqueueTransition(async () => { @@ -75,6 +72,8 @@ export class OfflineCoordinatorService { } clearActiveSession(): Promise { + this.#sync.revokeSession(); + this.#session.revokeAccess(); ++this.#transitionRevision; return this.#enqueueTransition(async () => { await this.#sync.resetSession(); diff --git a/projects/kit/offline/src/lib/offline-session.service.spec.ts b/projects/kit/offline/src/lib/offline-session.service.spec.ts index f75d020..3e7c3d8 100644 --- a/projects/kit/offline/src/lib/offline-session.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-session.service.spec.ts @@ -60,6 +60,16 @@ describe('OfflineSessionService shared-device boundary', () => { await expect(service.getSession()).resolves.toBeNull(); }); + it('永続削除を待たずにlocal/outboxとremote syncのruntime accessを失効する', async () => { + await service.activateOfflineSession('uid-A'); + + service.revokeAccess(); + + await expect(service.getLocalSession()).resolves.toBeNull(); + await expect(service.getSession()).resolves.toBeNull(); + await expect(service.getOfflineAccessManifest('uid-A')).resolves.toMatchObject({ userId: 10 }); + }); + it('既知のsubjectがmanifestと違う場合はlocal accessを拒否する', async () => { await expect(service.getOfflineAccessManifest('uid-B')).resolves.toBeNull(); await expect(service.getOfflineAccessManifest(null)).resolves.toBeNull(); diff --git a/projects/kit/offline/src/lib/offline-session.service.ts b/projects/kit/offline/src/lib/offline-session.service.ts index 8eac9dc..55eb9e3 100644 --- a/projects/kit/offline/src/lib/offline-session.service.ts +++ b/projects/kit/offline/src/lib/offline-session.service.ts @@ -98,6 +98,12 @@ export class OfflineSessionService { this.#remoteActivatedThisRun = false; } + /** Immediately revoke local and remote runtime access while durable cleanup is pending. */ + revokeAccess(): void { + this.#localAccessThisRun = false; + this.#remoteActivatedThisRun = false; + } + /** Disable remote pull/replay eligibility while retaining the verified local manifest. */ async suspendRemoteSession(): Promise { await this.initialize(); @@ -134,10 +140,7 @@ export class OfflineSessionService { * @param authSubject - A currently known provider subject. When supplied, it must match the * persisted subject. */ - async activateOfflineSession( - authSubject?: string | null, - lease?: OfflineSessionTransitionLease, - ): Promise { + async activateOfflineSession(authSubject?: string | null, lease?: OfflineSessionTransitionLease): Promise { const manifest = await this.getOfflineAccessManifest(authSubject); if (lease && !lease.isCurrent()) return null; this.#localAccessThisRun = manifest !== null; diff --git a/projects/kit/offline/src/lib/offline-sync.service.spec.ts b/projects/kit/offline/src/lib/offline-sync.service.spec.ts index f728df4..1e1f952 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -44,6 +44,7 @@ describe('OfflineSyncService', () => { let session: { userId: number; scopes: OfflineScope[] } | null; let localSession: { userId: number; scopes: OfflineScope[] } | null | undefined; let beforePutCommand: ((command: OfflineCommand) => Promise) | null; + let beforeGetReplicaRow: (() => Promise) | null; let pull: ReturnType Promise>>; let handleError: ReturnType void>>; const execute = vi.fn( @@ -57,6 +58,7 @@ describe('OfflineSyncService', () => { session = { userId: 1, scopes: [{ userId: 1, groupId: 10 }] }; localSession = undefined; beforePutCommand = null; + beforeGetReplicaRow = null; pull = vi.fn(async () => undefined); handleError = vi.fn(); execute.mockReset(); @@ -81,13 +83,15 @@ describe('OfflineSyncService', () => { removeCommand: vi.fn(async (commandId: string) => { commands = commands.filter((item) => item.commandId !== commandId); }), - getReplicaRow: vi.fn( - async (scope: OfflineScope, sourceKey: string, localId: string) => + getReplicaRow: vi.fn(async (scope: OfflineScope, sourceKey: string, localId: string) => { + await beforeGetReplicaRow?.(); + return ( rows.find( (item) => item.userId === scope.userId && item.groupId === scope.groupId && item.sourceKey === sourceKey && item.localId === localId, - ) ?? null, - ), + ) ?? null + ); + }), getReplicaRowByServerId: vi.fn( async (scope: OfflineScope, sourceKey: string, serverId: number) => rows.find( @@ -172,6 +176,65 @@ describe('OfflineSyncService', () => { expect(service.pendingCount()).toBe(1); }); + it('session失効前に開始したenqueueを永続commitせずreset完了まで直列化する', async () => { + let releaseRead: (() => void) | undefined; + let readStarted: (() => void) | undefined; + const started = new Promise((resolve) => { + readStarted = resolve; + }); + const gate = new Promise((resolve) => { + releaseRead = resolve; + }); + beforeGetReplicaRow = async () => { + readStarted?.(); + await gate; + }; + + const enqueue = service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: 'revoked', + operation: 'documents.create', + payload: { title: 'stale' }, + optimisticValue: { id: 0, title: 'stale' }, + }, + { flush: false }, + ); + await started; + + service.revokeSession(); + const reset = service.resetSession(); + releaseRead?.(); + + await expect(enqueue).rejects.toThrow('Offline session changed'); + await reset; + expect(rows).toEqual([]); + expect(commands).toEqual([]); + }); + + it('旧flushが失敗してもresetを中断せずdurable cleanupへ進める', async () => { + const pullError = new Error('pull failed during revocation'); + let rejectPull: ((error: unknown) => void) | undefined; + pull.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectPull = reject; + }), + ); + connected.set(true); + const flush = service.flush(); + const flushRejected = expect(flush).rejects.toBe(pullError); + await vi.waitFor(() => expect(pull).toHaveBeenCalledOnce()); + + const reset = service.resetSession(); + rejectPull?.(pullError); + + await flushRejected; + await expect(reset).resolves.toBeUndefined(); + expect(service.pendingCount()).toBe(0); + }); + it('同じaggregateの操作を作成順に送り、成功後だけoutboxから除く', async () => { await service.enqueue( { @@ -505,12 +568,13 @@ describe('OfflineSyncService', () => { connected.set(true); const oldFlush = service.flush(); await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce()); - await service.resetSession(); + const reset = service.resetSession(); + resolveExecute({ response: null, serverRevision: 2 }); + await reset; commands = commands.filter((command) => command.userId !== 1); connected.set(false); session = { userId: 2, scopes: [{ userId: 2, groupId: 20 }] }; await service.refreshSession(); - resolveExecute({ response: null, serverRevision: 2 }); await oldFlush; expect(execute).toHaveBeenCalledOnce(); expect(commands.some((command) => command.userId === 1)).toBe(false); @@ -534,10 +598,11 @@ describe('OfflineSyncService', () => { const oldFlush = service.flush(); await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce()); connected.set(false); - await service.resetSession(); + const reset = service.resetSession(); + resolveFirst({ response: null }); + await reset; await service.refreshSession(); expect(service.pendingCommands()[0]?.state).toBe('pending'); - resolveFirst({ response: null }); await oldFlush; connected.set(true); await service.flush(); diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts index fa8ddaa..c1e97ad 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -56,6 +56,7 @@ export class OfflineSyncService { readonly #knownScopes = new Map(); #activeUserId: number | null = null; #flushPromise: Promise | null = null; + readonly #flushTransitions = new Set>(); #generation = 0; readonly #sendingTransitions = new Set>(); #enqueueTail: Promise = Promise.resolve(); @@ -120,7 +121,8 @@ export class OfflineSyncService { } async resetSession(): Promise { - this.#invalidateFlush(); + this.revokeSession(); + await Promise.allSettled([this.#enqueueTail, ...this.#flushTransitions]); await this.#waitForSendingTransitions(); await this.#restoreInterruptedCommands(); this.#activeUserId = null; @@ -129,8 +131,14 @@ export class OfflineSyncService { this.#scheduleRetry(null); } + /** Synchronously invalidate in-flight enqueue and transport work owned by the current session. */ + revokeSession(): void { + this.#invalidateFlush(); + } + enqueue(request: EnqueueOfflineCommand, options: { flush?: boolean } = {}): Promise { - const enqueue = this.#enqueueTail.then(() => this.#enqueue(request, options)); + const generation = this.#generation; + const enqueue = this.#enqueueTail.then(() => this.#enqueue(request, options, generation)); this.#enqueueTail = enqueue.then( () => undefined, () => undefined, @@ -138,7 +146,7 @@ export class OfflineSyncService { return enqueue; } - async #enqueue(request: EnqueueOfflineCommand, options: { flush?: boolean }): Promise { + async #enqueue(request: EnqueueOfflineCommand, options: { flush?: boolean }, generation: number): Promise { await this.initialize(); const session = await this.#getLocalSession(); if (!session) throw new Error('Cannot enqueue an offline command without an authenticated user'); @@ -186,6 +194,9 @@ export class OfflineSyncService { fetchedAt: Date.now(), syncState: 'pending', }; + if (generation !== this.#generation) { + throw new Error('Offline session changed before the command could be persisted'); + } await this.#repository.transactReplica({ putRows: [optimisticRow], putCommands: [command] }); await this.#refreshState(); if (options.flush !== false && this.#network.connected()) this.#flushInBackground(); @@ -223,9 +234,11 @@ export class OfflineSyncService { if (this.#flushPromise) return this.#flushPromise; const generation = this.#generation; const promise = this.#runFlush(generation).finally(() => { + this.#flushTransitions.delete(promise); if (this.#flushPromise === promise) this.#flushPromise = null; }); this.#flushPromise = promise; + this.#flushTransitions.add(promise); return promise; } diff --git a/projects/kit/src/lib/auth/auth-access.service.spec.ts b/projects/kit/src/lib/auth/auth-access.service.spec.ts index 3e37882..6dfd49f 100644 --- a/projects/kit/src/lib/auth/auth-access.service.spec.ts +++ b/projects/kit/src/lib/auth/auth-access.service.spec.ts @@ -99,6 +99,165 @@ describe('KitAuthRecoveryService', () => { expect(access.mode).toBe('none'); }); + it('retries authentication while local access remains active even without a new availability event', async () => { + vi.useFakeTimers(); + const availability = new Subject(); + const transportError = { status: 0 }; + const reauthenticate = vi + .fn() + .mockRejectedValueOnce(transportError) + .mockResolvedValueOnce({ + activate: async () => true, + resume: async () => undefined, + }); + const { access, recovery } = setup({ + remoteRecovery: { availability: () => availability, retryDelayMs: 1000, reauthenticate }, + isUnavailableError: (error) => error === transportError, + }); + recovery.initialize(); + access.grantLocal(); + + await vi.advanceTimersByTimeAsync(1000); + expect(reauthenticate).toHaveBeenCalledTimes(1); + expect(access.mode).toBe('local'); + + await vi.advanceTimersByTimeAsync(1000); + expect(reauthenticate).toHaveBeenCalledTimes(2); + expect(access.mode).toBe('remote'); + vi.useRealTimers(); + }); + + it('does not bypass retryDelay for coalesced recovery calls in the same access revision', async () => { + vi.useFakeTimers(); + const availability = new Subject(); + let rejectOld: ((error: unknown) => void) | undefined; + const reauthenticate = vi + .fn() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectOld = reject; + }), + ) + .mockRejectedValue({ status: 0 }); + const { access, recovery } = setup({ + remoteRecovery: { availability: () => availability, retryDelayMs: 1_000, reauthenticate }, + isUnavailableError: (error) => (error as { status?: number })?.status === 0, + }); + recovery.initialize(); + access.grantLocal(); + availability.next(true); + const first = recovery.recover(); + availability.next(true); + recovery.recover(); + rejectOld?.({ status: 0 }); + await first; + + expect(reauthenticate).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(999); + expect(reauthenticate).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1); + expect(reauthenticate).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); + + it('reruns recovery for a newer local transition after the stale single flight settles', async () => { + const availability = new Subject(); + let releaseOld: (() => void) | undefined; + const oldGate = new Promise((resolve) => { + releaseOld = resolve; + }); + const reauthenticate = vi + .fn() + .mockImplementationOnce(async () => { + await oldGate; + return { + activate: async () => true, + resume: async () => undefined, + }; + }) + .mockResolvedValueOnce({ + activate: async () => true, + resume: async () => undefined, + }); + const { access, recovery } = setup({ + remoteRecovery: { availability: () => availability, reauthenticate }, + }); + recovery.initialize(); + access.grantLocal(); + availability.next(true); + await Promise.resolve(); + + access.grantLocal(); + releaseOld?.(); + await vi.waitFor(() => expect(reauthenticate).toHaveBeenCalledTimes(2)); + await recovery.recover(); + + expect(access.mode).toBe('remote'); + }); + + it.each([ + [0, 1_000], + [-1, 1_000], + [Number.NaN, 30_000], + [Number.POSITIVE_INFINITY, 30_000], + ])( + 'clamps invalid retryDelayMs %s instead of creating an immediate retry loop', + async (retryDelayMs, expectedDelayMs) => { + vi.useFakeTimers(); + const reauthenticate = vi.fn().mockRejectedValue({ status: 0 }); + const { access, recovery } = setup({ + remoteRecovery: { + availability: () => new Subject(), + retryDelayMs, + reauthenticate, + }, + isUnavailableError: () => true, + }); + recovery.initialize(); + access.grantLocal(); + + await vi.advanceTimersByTimeAsync(expectedDelayMs - 1); + expect(reauthenticate).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(reauthenticate).toHaveBeenCalledOnce(); + vi.useRealTimers(); + }, + ); + + it('does not resume or recreate a retry timer after destruction settles an in-flight recovery', async () => { + vi.useFakeTimers(); + let rejectRecovery: ((error: unknown) => void) | undefined; + const activate = vi.fn(async () => true); + const reauthenticate = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectRecovery = reject; + }), + ); + const { access, recovery } = setup({ + remoteRecovery: { + availability: () => new Subject(), + retryDelayMs: 1_000, + reauthenticate, + }, + isUnavailableError: () => true, + }); + recovery.initialize(); + access.grantLocal(); + const pending = recovery.recover(); + + TestBed.resetTestingModule(); + rejectRecovery?.({ status: 0 }); + await pending; + await vi.advanceTimersByTimeAsync(10_000); + + expect(reauthenticate).toHaveBeenCalledOnce(); + expect(activate).not.toHaveBeenCalled(); + expect(access.mode).toBe('local'); + vi.useRealTimers(); + }); + it('does not restore remote access after a newer access transition invalidates recovery', async () => { let resolveReauthentication: ((value: KitRemoteAccessRecovery) => void) | undefined; const activate = vi.fn(async () => true); diff --git a/projects/kit/src/lib/auth/auth-access.service.ts b/projects/kit/src/lib/auth/auth-access.service.ts index 9fc7289..3cbd38d 100644 --- a/projects/kit/src/lib/auth/auth-access.service.ts +++ b/projects/kit/src/lib/auth/auth-access.service.ts @@ -1,6 +1,6 @@ -import { ErrorHandler, inject, Injectable, InjectionToken } from '@angular/core'; -import type { Observable, Subscription } from 'rxjs'; -import { BehaviorSubject } from 'rxjs'; +import { DestroyRef, ErrorHandler, inject, Injectable, InjectionToken } from '@angular/core'; +import type { Observable } from 'rxjs'; +import { BehaviorSubject, Subscription } from 'rxjs'; /** Access level currently granted to the application runtime. */ export type KitAuthAccessMode = 'none' | 'local' | 'remote'; @@ -35,6 +35,12 @@ export interface KitAuthRecoveryConfig { isUnavailableError?(error: unknown): boolean; /** Optional online-recovery lifecycle used after local-only route access. */ remoteRecovery?: { + /** + * Delay before probing authentication again while local access remains active. + * + * @defaultValue 30000 + */ + retryDelayMs?: number; availability(): Observable; reauthenticate(lease: KitAuthAccessLease): Promise; }; @@ -115,26 +121,87 @@ export class KitAuthRecoveryService { readonly #access = inject(KitAuthAccessService); readonly #config = inject(KIT_AUTH_RECOVERY_CONFIG); readonly #errorHandler = inject(ErrorHandler); + readonly #destroyRef = inject(DestroyRef); #subscription: Subscription | null = null; #recovery: Promise | null = null; + #recoveryRevision: number | null = null; + #retryTimer: ReturnType | null = null; + #available = false; + #retryRequested = false; + #destroyed = false; + + constructor() { + this.#destroyRef.onDestroy(() => { + this.#destroyed = true; + this.#clearRetry(); + this.#subscription?.unsubscribe(); + this.#subscription = null; + }); + } /** Subscribe to the configured remote-availability stream once. */ initialize(): void { + if (this.#destroyed) return; const recovery = this.#config.remoteRecovery; if (!recovery || this.#subscription) return; - this.#subscription = recovery.availability().subscribe({ - next: (available) => { - if (available && this.#access.mode === 'local') void this.recover(); - }, - error: (error) => this.#errorHandler.handleError(error), - }); + this.#subscription = new Subscription(); + this.#subscription.add( + recovery.availability().subscribe({ + next: (available) => { + this.#available = available; + if (available && this.#access.mode === 'local') { + this.#clearRetry(); + void this.recover(); + } else if (this.#access.mode === 'local') { + this.#scheduleRetry(); + } + }, + error: (error) => this.#errorHandler.handleError(error), + }), + ); + this.#subscription.add( + this.#access.mode$.subscribe((mode) => { + if (mode !== 'local') { + this.#clearRetry(); + return; + } + if (this.#available) { + void this.recover(); + } else { + this.#scheduleRetry(); + } + }), + ); } /** Run one ordered remote recovery attempt, coalescing concurrent triggers. */ recover(): Promise { - if (this.#recovery) return this.#recovery; - const promise = this.#runRecovery().finally(() => { - if (this.#recovery === promise) this.#recovery = null; + if (this.#destroyed) return Promise.resolve(); + if (this.#recovery) { + if ( + this.#access.mode === 'local' && + this.#recoveryRevision !== null && + this.#access.revision > this.#recoveryRevision + ) { + this.#retryRequested = true; + } + return this.#recovery; + } + const running = this.#runRecovery(); + this.#recoveryRevision = this.#access.revision; + const promise = running.finally(() => { + if (this.#recovery !== promise) return; + this.#recovery = null; + this.#recoveryRevision = null; + if (this.#destroyed) return; + if (!this.#retryRequested) return; + this.#retryRequested = false; + if (this.#access.mode !== 'local') return; + if (this.#available) { + void this.recover(); + } else { + this.#scheduleRetry(); + } }); this.#recovery = promise; return promise; @@ -142,30 +209,62 @@ export class KitAuthRecoveryService { async #runRecovery(): Promise { const recovery = this.#config.remoteRecovery; - if (!recovery || this.#access.mode !== 'local') return; + if (this.#destroyed || !recovery || this.#access.mode !== 'local') return; const lease = this.#access.beginTransition(); let expectedRevision = this.#access.revision; const isCurrent = (): boolean => this.#access.revision === expectedRevision; try { const result = await recovery.reauthenticate(lease); - if (!isCurrent() || this.#access.mode !== 'local') return; + if (this.#destroyed || !isCurrent() || this.#access.mode !== 'local') return; if (result === false) { + this.#clearRetry(); this.#access.clear(); return; } - if (!(await result.activate(lease)) || !isCurrent() || this.#access.mode !== 'local') return; + if ( + !(await result.activate(lease)) || + this.#destroyed || + !isCurrent() || + this.#access.mode !== 'local' + ) { + return; + } + this.#clearRetry(); this.#access.grantRemote(); expectedRevision = this.#access.revision; await result.resume(); } catch (error) { - if (!isCurrent()) return; + if (this.#destroyed || !isCurrent()) return; if (isExplicitAuthDenial(error)) { + this.#clearRetry(); this.#access.clear(); - } else if (!this.#config.isUnavailableError?.(error)) { + } else if (this.#config.isUnavailableError?.(error)) { + this.#scheduleRetry(); + } else { this.#errorHandler.handleError(error); } } } + + #scheduleRetry(): void { + const recovery = this.#config.remoteRecovery; + if (this.#destroyed || !recovery || this.#retryTimer || this.#access.mode !== 'local') return; + this.#retryTimer = setTimeout(() => { + this.#retryTimer = null; + if (this.#access.mode === 'local') void this.recover(); + }, this.#retryDelayMs(recovery.retryDelayMs)); + } + + #clearRetry(): void { + if (!this.#retryTimer) return; + clearTimeout(this.#retryTimer); + this.#retryTimer = null; + } + + #retryDelayMs(configured: number | undefined): number { + if (configured === undefined || !Number.isFinite(configured)) return 30_000; + return Math.max(1_000, configured); + } } /** Returns true for authoritative authentication denials that must never use local fallback. */