Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/app/api/resilience/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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),
});

Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 10 additions & 0 deletions src/shared/validation/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment on lines +1164 to +1170

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a discrepancy between the maximum limits enforced by this Zod schema and those allowed by the backend normalization logic in src/lib/resilience/settings.ts:

  • minRetryCooldownMs: Schema max is 300000 (5 minutes), but the backend allows up to 3600000 (1 hour).
  • maxRetryCooldownMs: Schema max is 3600000 (1 hour), but the backend allows up to 86400000 (24 hours).

This discrepancy will cause the API to reject valid configurations that the backend is fully capable of supporting. We should align the schema limits with the backend's normalization limits.

Additionally, we can add a refinement to ensure minRetryCooldownMs is less than or equal to maxRetryCooldownMs when both are provided.

const providerCooldownSettingsSchema = z
  .object({
    enabled: z.boolean().optional(),
    minRetryCooldownMs: z.number().int().min(0).max(3600000).optional(),
    maxRetryCooldownMs: z.number().int().min(0).max(86400000).optional(),
  })
  .strict()
  .superRefine((value, ctx) => {
    if (
      typeof value.minRetryCooldownMs === "number" &&
      typeof value.maxRetryCooldownMs === "number" &&
      value.minRetryCooldownMs > value.maxRetryCooldownMs
    ) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "minRetryCooldownMs must be less than or equal to maxRetryCooldownMs",
        path: ["maxRetryCooldownMs"],
      });
    }
  });


export const updateResilienceSchema = z
.object({
requestQueue: requestQueueSettingsSchema.optional(),
Expand All @@ -1179,6 +1187,7 @@ export const updateResilienceSchema = z
.strict()
.optional(),
waitForCooldown: waitForCooldownSettingsSchema.optional(),
providerCooldown: providerCooldownSettingsSchema.optional(),
profiles: z
.object({
oauth: legacyResilienceProfileSchema.optional(),
Expand All @@ -1195,6 +1204,7 @@ export const updateResilienceSchema = z
!value.connectionCooldown &&
!value.providerBreaker &&
!value.waitForCooldown &&
!value.providerCooldown &&
!value.profiles &&
!value.defaults
) {
Expand Down
65 changes: 65 additions & 0 deletions tests/unit/resilience-provider-cooldown-api-3556.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});