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:
5
.changeset/fn-5778-oauth-notify-throttle.md
Normal file
5
.changeset/fn-5778-oauth-notify-throttle.md
Normal 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.
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user