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

@@ -121,6 +121,46 @@ describe("OAuthExpiryMonitor", () => {
now += 2_000;
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);
monitor.stop();
vi.useRealTimers();

View File

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