From 7d7a45ae2848bb1ecdcfc72aa9947974501adea5 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:34:33 -0300 Subject: [PATCH] fix(resilience): expose providerCooldown in GET+PATCH /api/resilience (#3590) --- src/app/api/resilience/route.ts | 8 +++ src/shared/validation/schemas.ts | 10 +++ ...ilience-provider-cooldown-api-3556.test.ts | 65 +++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 tests/unit/resilience-provider-cooldown-api-3556.test.ts diff --git a/src/app/api/resilience/route.ts b/src/app/api/resilience/route.ts index 935dfcc987..1bda9354c1 100644 --- a/src/app/api/resilience/route.ts +++ b/src/app/api/resilience/route.ts @@ -133,6 +133,7 @@ export async function GET() { maxRetries: resilience.waitForCooldown.maxRetries, maxRetryWaitSec: resilience.waitForCooldown.maxRetryWaitSec, }, + providerCooldown: resilience.providerCooldown, legacy: buildLegacyResilienceCompat(resilience), }); } catch (err: unknown) { @@ -188,6 +189,12 @@ export async function PATCH(request) { ...(body.waitForCooldown ? { waitForCooldown: body.waitForCooldown as ResilienceSettingsPatch["waitForCooldown"] } : {}), + ...(body.providerCooldown + ? { + providerCooldown: + body.providerCooldown as ResilienceSettingsPatch["providerCooldown"], + } + : {}), ...normalizeLegacyPatch(body), }); @@ -222,6 +229,7 @@ export async function PATCH(request) { maxRetries: nextResilience.waitForCooldown.maxRetries, maxRetryWaitSec: nextResilience.waitForCooldown.maxRetryWaitSec, }, + providerCooldown: nextResilience.providerCooldown, legacy: buildLegacyResilienceCompat(nextResilience), }); } catch (err: unknown) { diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index dd570bb5fa..fac4880acd 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1161,6 +1161,14 @@ const waitForCooldownSettingsSchema = z }) .strict(); +const providerCooldownSettingsSchema = z + .object({ + enabled: z.boolean().optional(), + minRetryCooldownMs: z.number().int().min(0).max(300000).optional(), + maxRetryCooldownMs: z.number().int().min(0).max(3600000).optional(), + }) + .strict(); + export const updateResilienceSchema = z .object({ requestQueue: requestQueueSettingsSchema.optional(), @@ -1179,6 +1187,7 @@ export const updateResilienceSchema = z .strict() .optional(), waitForCooldown: waitForCooldownSettingsSchema.optional(), + providerCooldown: providerCooldownSettingsSchema.optional(), profiles: z .object({ oauth: legacyResilienceProfileSchema.optional(), @@ -1195,6 +1204,7 @@ export const updateResilienceSchema = z !value.connectionCooldown && !value.providerBreaker && !value.waitForCooldown && + !value.providerCooldown && !value.profiles && !value.defaults ) { diff --git a/tests/unit/resilience-provider-cooldown-api-3556.test.ts b/tests/unit/resilience-provider-cooldown-api-3556.test.ts new file mode 100644 index 0000000000..7de0718772 --- /dev/null +++ b/tests/unit/resilience-provider-cooldown-api-3556.test.ts @@ -0,0 +1,65 @@ +/** + * Regression test for #3556 / fix: providerCooldown missing from GET+PATCH /api/resilience + * + * The ResilienceTab component fetches GET /api/resilience and expects a `providerCooldown` + * field; likewise PATCH must accept and persist it. Without both, the Settings → Resilience + * page crashes on load and cannot save the provider-cooldown configuration. + */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +// Validate the schema accepts providerCooldown +import { updateResilienceSchema } from "../../src/shared/validation/schemas.js"; +// Validate settings roundtrip +import { + resolveResilienceSettings, + mergeResilienceSettings, +} from "../../src/lib/resilience/settings.js"; + +describe("providerCooldown in updateResilienceSchema", () => { + it("accepts a valid providerCooldown patch", () => { + const result = updateResilienceSchema.safeParse({ + providerCooldown: { + enabled: true, + minRetryCooldownMs: 3000, + maxRetryCooldownMs: 120000, + }, + }); + assert.equal(result.success, true, `Schema rejected valid patch: ${JSON.stringify(result)}`); + }); + + it("accepts partial providerCooldown patch", () => { + const result = updateResilienceSchema.safeParse({ + providerCooldown: { enabled: false }, + }); + assert.equal(result.success, true, `Schema rejected partial patch: ${JSON.stringify(result)}`); + }); + + it("rejects unknown keys inside providerCooldown", () => { + const result = updateResilienceSchema.safeParse({ + providerCooldown: { enabled: true, unknownKey: 42 }, + }); + assert.equal(result.success, false, "Schema should reject unknown keys"); + }); +}); + +describe("providerCooldown roundtrip through mergeResilienceSettings", () => { + it("merges providerCooldown overrides correctly", () => { + const base = resolveResilienceSettings({}); + const merged = mergeResilienceSettings(base, { + providerCooldown: { enabled: true, minRetryCooldownMs: 2000 }, + }); + assert.equal(merged.providerCooldown.enabled, true); + assert.equal(merged.providerCooldown.minRetryCooldownMs, 2000); + // maxRetryCooldownMs should remain from default + assert.ok(merged.providerCooldown.maxRetryCooldownMs > 0); + }); + + it("resolveResilienceSettings returns providerCooldown field", () => { + const settings = resolveResilienceSettings({}); + assert.ok("providerCooldown" in settings, "providerCooldown missing from resolved settings"); + assert.ok("enabled" in settings.providerCooldown); + assert.ok("minRetryCooldownMs" in settings.providerCooldown); + assert.ok("maxRetryCooldownMs" in settings.providerCooldown); + }); +});