FN-5778: throttle OAuth expiry notifications per provider

Limit repeated oauth-token-expired alerts by enforcing a per-provider minimum notification interval.

- add a 12-hour default min notify interval to OAuthExpiryMonitor and track last notification time by provider
- skip dispatch when a provider was already notified within the configured window, even if expiry timestamps change
- clear per-provider notification timestamps when no OAuth providers are configured
- add regression coverage for 12-hour throttling and changed-expiry throttling behavior
- add a patch changeset for @runfusion/fusion documenting the notification throttle

Files changed:
 .changeset/fn-5778-oauth-notify-throttle.md        |  5 +++
 packages/engine/src/notification/__tests__/oauth-expiry-monitor.test.ts         | 40 ++++++++++++++++++++++
 packages/engine/src/notification/oauth-expiry-monitor.ts       | 15 ++++++++
 3 files changed, 60 insertions(+)

Fusion-Task-Id: FN-5778

Fusion-Task-Lineage: a9741101-e8da-4dae-ac7f-817e564b4f23
This commit is contained in:
gsxdsm
2026-05-31 09:21:19 -07:00
parent 62bc1e4458
commit 9f29935525
3 changed files with 60 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Throttle `oauth-token-expired` notifications to at most once per provider every 12 hours, even when the credential `expires` timestamp changes across refreshes/replacements.

View File

@@ -121,6 +121,46 @@ describe("OAuthExpiryMonitor", () => {
now += 2_000; now += 2_000;
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
expect(dispatch).toHaveBeenCalledTimes(1);
now += 12 * 60 * 60 * 1000;
await vi.advanceTimersByTimeAsync(100);
expect(dispatch).toHaveBeenCalledTimes(2);
monitor.stop();
vi.useRealTimers();
});
it("throttles changed expiries until min notify interval elapses", async () => {
vi.useFakeTimers();
let now = Date.now();
const authStorage = createAuthStorage({ type: "oauth", expires: now - 1 });
const dispatch = vi.fn(async () => undefined);
const monitor = new OAuthExpiryMonitor({
authStorage,
notificationService: { dispatch } as any,
intervalMs: 100,
minNotifyIntervalMs: 1_000,
clock: () => now,
});
await monitor.start();
expect(dispatch).toHaveBeenCalledTimes(1);
authStorage.credential = { type: "oauth", expires: now + 10_000 };
await vi.advanceTimersByTimeAsync(100);
now += 500;
authStorage.credential = { type: "oauth", expires: now - 1 };
await vi.advanceTimersByTimeAsync(100);
expect(dispatch).toHaveBeenCalledTimes(1);
now += 500;
authStorage.credential = { type: "oauth", expires: now - 2 };
await vi.advanceTimersByTimeAsync(100);
expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledTimes(2);
monitor.stop(); monitor.stop();
vi.useRealTimers(); vi.useRealTimers();

View File

@@ -3,6 +3,7 @@ import { schedulerLog } from "../logger.js";
import type { NotificationService } from "./notification-service.js"; import type { NotificationService } from "./notification-service.js";
const DEFAULT_INTERVAL_MS = 5 * 60_000; const DEFAULT_INTERVAL_MS = 5 * 60_000;
const DEFAULT_MIN_NOTIFY_INTERVAL_MS = 12 * 60 * 60 * 1000;
interface OAuthProviderInfo { interface OAuthProviderInfo {
id: string; id: string;
@@ -26,19 +27,23 @@ export interface OAuthExpiryMonitorOptions {
intervalMs?: number; intervalMs?: number;
clock?: () => number; clock?: () => number;
warnBeforeMs?: number; warnBeforeMs?: number;
minNotifyIntervalMs?: number;
} }
export class OAuthExpiryMonitor { export class OAuthExpiryMonitor {
private readonly intervalMs: number; private readonly intervalMs: number;
private readonly clock: () => number; private readonly clock: () => number;
private readonly warnBeforeMs: number; private readonly warnBeforeMs: number;
private readonly minNotifyIntervalMs: number;
private timer: NodeJS.Timeout | null = null; private timer: NodeJS.Timeout | null = null;
private readonly dispatchedExpiryKeys = new Set<string>(); private readonly dispatchedExpiryKeys = new Set<string>();
private readonly lastNotifiedAt = new Map<string, number>();
constructor(private readonly opts: OAuthExpiryMonitorOptions) { constructor(private readonly opts: OAuthExpiryMonitorOptions) {
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS; this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
this.clock = opts.clock ?? Date.now; this.clock = opts.clock ?? Date.now;
this.warnBeforeMs = opts.warnBeforeMs ?? 0; this.warnBeforeMs = opts.warnBeforeMs ?? 0;
this.minNotifyIntervalMs = opts.minNotifyIntervalMs ?? DEFAULT_MIN_NOTIFY_INTERVAL_MS;
} }
async start(): Promise<void> { async start(): Promise<void> {
@@ -68,6 +73,7 @@ export class OAuthExpiryMonitor {
const providers = this.opts.authStorage.getOAuthProviders?.(); const providers = this.opts.authStorage.getOAuthProviders?.();
if (!providers?.length) { if (!providers?.length) {
this.dispatchedExpiryKeys.clear(); this.dispatchedExpiryKeys.clear();
this.lastNotifiedAt.clear();
return; return;
} }
@@ -90,6 +96,14 @@ export class OAuthExpiryMonitor {
continue; continue;
} }
const previousNotificationAt = this.lastNotifiedAt.get(provider.id);
if (
typeof previousNotificationAt === "number" &&
now - previousNotificationAt < this.minNotifyIntervalMs
) {
continue;
}
const payload: NotificationPayload = { const payload: NotificationPayload = {
event: "oauth-token-expired", event: "oauth-token-expired",
metadata: { metadata: {
@@ -102,6 +116,7 @@ export class OAuthExpiryMonitor {
try { try {
await this.opts.notificationService.dispatch("oauth-token-expired", payload); await this.opts.notificationService.dispatch("oauth-token-expired", payload);
this.dispatchedExpiryKeys.add(expiryKey); this.dispatchedExpiryKeys.add(expiryKey);
this.lastNotifiedAt.set(provider.id, now);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
schedulerLog.warn(`OAuth expiry notification dispatch failed provider=${provider.id}: ${message}`); schedulerLog.warn(`OAuth expiry notification dispatch failed provider=${provider.id}: ${message}`);