diff --git a/packages/policy-explanation/package.json b/packages/policy-explanation/package.json new file mode 100644 index 0000000..cf13483 --- /dev/null +++ b/packages/policy-explanation/package.json @@ -0,0 +1,19 @@ +{ + "name": "@guildpass/policy-explanation", + "version": "2.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "node --test dist/**/*.test.js" + } +} diff --git a/packages/policy-explanation/src/index.test.ts b/packages/policy-explanation/src/index.test.ts new file mode 100644 index 0000000..8fc45d1 --- /dev/null +++ b/packages/policy-explanation/src/index.test.ts @@ -0,0 +1,682 @@ +/** + * Unit tests for the Policy Explanation Engine + */ + +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + explainDecision, + condition, + all, + any, + not, + ExplanationError, + isConditionNode, + isAllNode, + isAnyNode, + isNotNode, + type EvaluationNode, + type DecisionExplanation +} from "./index.js"; + +describe("Policy Explanation Engine", () => { + describe("Basic Condition Nodes", () => { + it("should explain a passing condition", () => { + const node = condition("cond1", true, "User is active"); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, true); + assert.strictEqual(result.reasons.length, 1); + assert.strictEqual(result.reasons[0].code, "PASS_COND"); + assert.strictEqual(result.reasons[0].nodeId, "cond1"); + assert.strictEqual(result.reasons[0].message, "User is active"); + }); + + it("should explain a failing condition", () => { + const node = condition("cond1", false, "User is inactive"); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, false); + assert.strictEqual(result.reasons.length, 1); + assert.strictEqual(result.reasons[0].code, "FAIL_COND"); + assert.strictEqual(result.reasons[0].nodeId, "cond1"); + assert.strictEqual(result.reasons[0].message, "User is inactive"); + }); + + it("should handle condition without reason", () => { + const node = condition("cond1", true); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, true); + assert.strictEqual(result.reasons.length, 1); + assert.strictEqual(result.reasons[0].message, undefined); + }); + }); + + describe("ALL Nodes (Logical AND)", () => { + it("should pass when all children pass", () => { + const node = all( + condition("cond1", true), + condition("cond2", true), + condition("cond3", true) + ); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, true); + assert.strictEqual(result.reasons.length, 1); + assert.strictEqual(result.reasons[0].code, "PASS_ALL"); + }); + + it("should fail when any child fails", () => { + const node = all( + condition("cond1", true), + condition("cond2", false, "Missing role"), + condition("cond3", true) + ); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, false); + assert.ok(result.reasons.length >= 1); + assert.ok(result.reasons.some(r => r.code === "FAIL_COND" && r.nodeId === "cond2")); + }); + + it("should fail when multiple children fail", () => { + const node = all( + condition("cond1", false, "Failed 1"), + condition("cond2", false, "Failed 2"), + condition("cond3", true) + ); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, false); + // Should report all failing conditions + const failingReasons = result.reasons.filter(r => r.code === "FAIL_COND"); + assert.ok(failingReasons.length >= 2); + }); + + it("should handle nested ALL nodes", () => { + const node = all( + condition("cond1", true), + all( + condition("cond2", true), + condition("cond3", true) + ) + ); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, true); + }); + + it("should handle nested ALL with failure", () => { + const node = all( + condition("cond1", true), + all( + condition("cond2", true), + condition("cond3", false, "Nested failure") + ) + ); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, false); + assert.ok(result.reasons.some(r => r.nodeId === "cond3")); + }); + }); + + describe("ANY Nodes (Logical OR)", () => { + it("should pass when at least one child passes", () => { + const node = any( + condition("cond1", false), + condition("cond2", true, "Has admin role"), + condition("cond3", false) + ); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, true); + assert.ok(result.reasons.some(r => r.code === "PASS_COND" && r.nodeId === "cond2")); + }); + + it("should fail when all children fail", () => { + const node = any( + condition("cond1", false, "No role A"), + condition("cond2", false, "No role B"), + condition("cond3", false, "No role C") + ); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, false); + assert.ok(result.reasons.some(r => r.code === "FAIL_ANY")); + assert.ok(result.reasons.some(r => r.message === "No conditions passed")); + }); + + it("should report first passing child", () => { + const node = any( + condition("cond1", true, "First pass"), + condition("cond2", true, "Second pass"), + condition("cond3", true, "Third pass") + ); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, true); + // Should only report the first passing child + const passingReasons = result.reasons.filter(r => r.code === "PASS_COND"); + assert.strictEqual(passingReasons.length, 1); + assert.strictEqual(passingReasons[0].nodeId, "cond1"); + }); + + it("should handle nested ANY nodes", () => { + const node = any( + condition("cond1", false), + any( + condition("cond2", false), + condition("cond3", true, "Nested pass") + ) + ); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, true); + assert.ok(result.reasons.some(r => r.nodeId === "cond3")); + }); + + it("should handle nested ANY with all failures", () => { + const node = any( + condition("cond1", false), + any( + condition("cond2", false), + condition("cond3", false) + ) + ); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, false); + }); + }); + + describe("NOT Nodes (Logical Negation)", () => { + it("should pass when child fails", () => { + const node = not(condition("cond1", false, "User is blocked")); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, true); + assert.strictEqual(result.reasons[0].code, "PASS_NOT"); + assert.strictEqual(result.reasons[0].message, "Negated condition failed"); + }); + + it("should fail when child passes", () => { + const node = not(condition("cond1", true, "User is verified")); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, false); + assert.strictEqual(result.reasons[0].code, "FAIL_NOT"); + assert.strictEqual(result.reasons[0].message, "Negated condition passed"); + }); + + it("should include child details", () => { + const node = not(condition("cond1", false, "Blocked")); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, true); + // Should include NOT reason and child condition reason + assert.ok(result.reasons.length >= 2); + assert.ok(result.reasons.some(r => r.code === "PASS_NOT")); + assert.ok(result.reasons.some(r => r.code === "FAIL_COND")); + }); + + it("should handle nested NOT nodes", () => { + const node = not(not(condition("cond1", true))); + const result = explainDecision(node); + + // Double negation should return original value + assert.strictEqual(result.allowed, true); + }); + + it("should handle NOT with complex children", () => { + const node = not( + all( + condition("cond1", true), + condition("cond2", false, "Missing requirement") + ) + ); + const result = explainDecision(node); + + // ALL fails, so NOT passes + assert.strictEqual(result.allowed, true); + }); + }); + + describe("Mixed Nested Structures", () => { + it("should handle complex nested policy", () => { + const node = all( + condition("user_active", true), + any( + condition("has_admin_role", true), + all( + condition("has_editor_role", true), + condition("content_owned", true) + ) + ), + not(condition("is_suspended", false)) + ); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, true); + }); + + it("should handle complex nested policy with failure", () => { + const node = all( + condition("user_active", true), + any( + condition("has_admin_role", false), + all( + condition("has_editor_role", true), + condition("content_owned", false, "Not owner") + ) + ), + not(condition("is_suspended", false)) + ); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, false); + }); + + it("should handle deeply nested structure", () => { + const node = all( + all( + all( + condition("cond1", true), + condition("cond2", true) + ), + condition("cond3", true) + ), + condition("cond4", true) + ); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, true); + }); + }); + + describe("Deterministic Ordering", () => { + it("should produce identical output for equivalent trees", () => { + const node1 = all( + condition("cond1", true), + condition("cond2", false), + condition("cond3", true) + ); + const node2 = all( + condition("cond1", true), + condition("cond2", false), + condition("cond3", true) + ); + + const result1 = explainDecision(node1); + const result2 = explainDecision(node2); + + assert.deepStrictEqual(result1, result2); + }); + + it("should sort reasons deterministically", () => { + const node = all( + condition("cond_z", false), + condition("cond_a", false), + condition("cond_m", false) + ); + const result = explainDecision(node); + + // Reasons should be sorted by nodeId + const nodeIds = result.reasons.map(r => r.nodeId); + const sortedNodeIds = [...nodeIds].sort(); + assert.deepStrictEqual(nodeIds, sortedNodeIds); + }); + + it("should maintain consistent ordering across multiple calls", () => { + const node = any( + condition("cond3", true), + condition("cond1", false), + condition("cond2", false) + ); + + const results = Array.from({ length: 5 }, () => explainDecision(node)); + + for (let i = 1; i < results.length; i++) { + assert.deepStrictEqual(results[0], results[i]); + } + }); + }); + + describe("Depth Limit Validation", () => { + it("should accept tree within depth limit", () => { + const node = all( + condition("cond1", true), + all( + condition("cond2", true), + all( + condition("cond3", true), + condition("cond4", true) + ) + ) + ); + const result = explainDecision(node, { maxDepth: 10 }); + + assert.strictEqual(result.allowed, true); + }); + + it("should reject tree exceeding depth limit", () => { + // Create a tree with depth 51 (exceeds default of 50) + let node: EvaluationNode = condition("deep", true); + for (let i = 0; i < 51; i++) { + node = all(node); + } + + assert.throws( + () => explainDecision(node), + (error: Error) => { + assert.ok(error instanceof ExplanationError); + assert.ok(error.message.includes("maximum depth")); + return true; + } + ); + }); + + it("should respect custom depth limit", () => { + let node: EvaluationNode = condition("deep", true); + for (let i = 0; i < 5; i++) { + node = all(node); + } + + assert.throws( + () => explainDecision(node, { maxDepth: 3 }), + (error: Error) => { + assert.ok(error instanceof ExplanationError); + assert.ok(error.message.includes("maximum depth")); + return true; + } + ); + }); + }); + + describe("Node Count Limit Validation", () => { + it("should accept tree within node count limit", () => { + const children = Array.from({ length: 100 }, (_, i) => + condition(`cond${i}`, true) + ); + const node = any(...children); + const result = explainDecision(node, { maxNodes: 1000 }); + + assert.strictEqual(result.allowed, true); + }); + + it("should reject tree exceeding node count limit", () => { + // Create a tree with 1001 nodes (exceeds default of 1000) + const children = Array.from({ length: 1001 }, (_, i) => + condition(`cond${i}`, true) + ); + const node = any(...children); + + assert.throws( + () => explainDecision(node), + (error: Error) => { + assert.ok(error instanceof ExplanationError); + assert.ok(error.message.includes("maximum node count")); + return true; + } + ); + }); + + it("should respect custom node count limit", () => { + const children = Array.from({ length: 11 }, (_, i) => + condition(`cond${i}`, true) + ); + const node = any(...children); + + assert.throws( + () => explainDecision(node, { maxNodes: 10 }), + (error: Error) => { + assert.ok(error instanceof ExplanationError); + assert.ok(error.message.includes("maximum node count")); + return true; + } + ); + }); + }); + + describe("Malformed Input Rejection", () => { + it("should reject condition node without id", () => { + const node = { type: "condition" as const, id: "", passed: true }; + + assert.throws( + () => explainDecision(node), + (error: Error) => { + assert.ok(error instanceof ExplanationError); + assert.ok(error.message.includes("non-empty id")); + return true; + } + ); + }); + + it("should reject condition node with invalid passed field", () => { + const node = { type: "condition" as const, id: "cond1", passed: "true" as any }; + + assert.throws( + () => explainDecision(node), + (error: Error) => { + assert.ok(error instanceof ExplanationError); + assert.ok(error.message.includes("boolean passed field")); + return true; + } + ); + }); + + it("should reject ALL node without children array", () => { + const node = { type: "all" as const, children: null as any }; + + assert.throws( + () => explainDecision(node), + (error: Error) => { + assert.ok(error instanceof ExplanationError); + assert.ok(error.message.includes("children array")); + return true; + } + ); + }); + + it("should reject ANY node without children array", () => { + const node = { type: "any" as const, children: "invalid" as any }; + + assert.throws( + () => explainDecision(node), + (error: Error) => { + assert.ok(error instanceof ExplanationError); + assert.ok(error.message.includes("children array")); + return true; + } + ); + }); + + it("should reject NOT node without child", () => { + const node = { type: "not" as const, child: null as any }; + + assert.throws( + () => explainDecision(node), + (error: Error) => { + assert.ok(error instanceof ExplanationError); + assert.ok(error.message.includes("must have a child")); + return true; + } + ); + }); + + it("should reject unknown node type", () => { + const node = { type: "unknown" as any, children: [] }; + + assert.throws( + () => explainDecision(node), + (error: Error) => { + assert.ok(error instanceof ExplanationError); + assert.ok(error.message.includes("Unknown node type")); + return true; + } + ); + }); + }); + + describe("Type Guards", () => { + it("should identify condition nodes", () => { + const node = condition("cond1", true); + assert.strictEqual(isConditionNode(node), true); + assert.strictEqual(isAllNode(node), false); + assert.strictEqual(isAnyNode(node), false); + assert.strictEqual(isNotNode(node), false); + }); + + it("should identify ALL nodes", () => { + const node = all(condition("cond1", true)); + assert.strictEqual(isConditionNode(node), false); + assert.strictEqual(isAllNode(node), true); + assert.strictEqual(isAnyNode(node), false); + assert.strictEqual(isNotNode(node), false); + }); + + it("should identify ANY nodes", () => { + const node = any(condition("cond1", true)); + assert.strictEqual(isConditionNode(node), false); + assert.strictEqual(isAllNode(node), false); + assert.strictEqual(isAnyNode(node), true); + assert.strictEqual(isNotNode(node), false); + }); + + it("should identify NOT nodes", () => { + const node = not(condition("cond1", true)); + assert.strictEqual(isConditionNode(node), false); + assert.strictEqual(isAllNode(node), false); + assert.strictEqual(isAnyNode(node), false); + assert.strictEqual(isNotNode(node), true); + }); + }); + + describe("Side-Effect Free", () => { + it("should not modify input tree", () => { + const originalNode = all( + condition("cond1", true), + condition("cond2", false) + ); + + // Store original values for comparison + const originalType = originalNode.type; + const originalChildren = originalNode.children.map(child => ({ + type: child.type, + id: (child as any).id, + passed: (child as any).passed, + reason: (child as any).reason + })); + + explainDecision(originalNode); + + // Verify no modifications + assert.strictEqual(originalNode.type, originalType); + assert.strictEqual(originalNode.children.length, originalChildren.length); + for (let i = 0; i < originalNode.children.length; i++) { + const child = originalNode.children[i]; + const originalChild = originalChildren[i]; + assert.strictEqual(child.type, originalChild.type); + assert.strictEqual((child as any).id, originalChild.id); + assert.strictEqual((child as any).passed, originalChild.passed); + assert.strictEqual((child as any).reason, originalChild.reason); + } + }); + + it("should produce independent results for each call", () => { + const node = condition("cond1", true); + const result1 = explainDecision(node); + const result2 = explainDecision(node); + + // Results should be equal but not the same object + assert.deepStrictEqual(result1, result2); + assert.notStrictEqual(result1.reasons, result2.reasons); + }); + }); + + describe("Edge Cases", () => { + it("should handle empty ALL node", () => { + const node = all(); + const result = explainDecision(node); + + // Empty ALL should pass (vacuously true) + assert.strictEqual(result.allowed, true); + }); + + it("should handle empty ANY node", () => { + const node = any(); + const result = explainDecision(node); + + // Empty ANY should fail (no conditions to pass) + assert.strictEqual(result.allowed, false); + }); + + it("should handle single child in ALL", () => { + const node = all(condition("cond1", true)); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, true); + }); + + it("should handle single child in ANY", () => { + const node = any(condition("cond1", true)); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, true); + }); + + it("should handle very long condition id", () => { + const longId = "a".repeat(10000); + const node = condition(longId, true); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, true); + assert.strictEqual(result.reasons[0].nodeId, longId); + }); + + it("should handle special characters in condition id", () => { + const node = condition("cond-with_special.chars", true); + const result = explainDecision(node); + + assert.strictEqual(result.allowed, true); + assert.strictEqual(result.reasons[0].nodeId, "cond-with_special.chars"); + }); + }); + + describe("Reason Code Stability", () => { + it("should generate consistent reason codes", () => { + const node = condition("cond1", true); + const result = explainDecision(node); + + assert.strictEqual(result.reasons[0].code, "PASS_COND"); + }); + + it("should generate different codes for different outcomes", () => { + const passNode = condition("cond1", true); + const failNode = condition("cond1", false); + + const passResult = explainDecision(passNode); + const failResult = explainDecision(failNode); + + assert.strictEqual(passResult.reasons[0].code, "PASS_COND"); + assert.strictEqual(failResult.reasons[0].code, "FAIL_COND"); + }); + + it("should generate appropriate codes for logical operators", () => { + const allNode = all(condition("cond1", true)); + const anyNode = any(condition("cond1", true)); + const notNode = not(condition("cond1", false)); + + const allResult = explainDecision(allNode); + const anyResult = explainDecision(anyNode); + const notResult = explainDecision(notNode); + + // ALL and ANY should have PASS codes as first reason + assert.ok(allResult.reasons[0].code.startsWith("PASS_")); + assert.ok(anyResult.reasons[0].code.startsWith("PASS_")); + // NOT should have PASS_NOT as first reason due to sorting priority + assert.strictEqual(notResult.reasons[0].code, "PASS_NOT"); + }); + }); +}); diff --git a/packages/policy-explanation/src/index.ts b/packages/policy-explanation/src/index.ts new file mode 100644 index 0000000..060d6e9 --- /dev/null +++ b/packages/policy-explanation/src/index.ts @@ -0,0 +1,428 @@ +/** + * Policy Explanation Engine + * + * A standalone, side-effect-free engine for explaining policy evaluation decisions. + * Accepts a tree of evaluated policy conditions and produces deterministic, + * structured explanations suitable for logs, tests, and access decisions. + */ + +// ============================================================================ +// Type Definitions +// ============================================================================ + +/** + * A node in the evaluation tree representing a policy condition or logical operation. + */ +export type EvaluationNode = + | ConditionNode + | AllNode + | AnyNode + | NotNode; + +/** + * A leaf node representing a single condition evaluation. + */ +export interface ConditionNode { + type: "condition"; + id: string; + passed: boolean; + reason?: string; +} + +/** + * A logical AND node - all children must pass. + */ +export interface AllNode { + type: "all"; + children: EvaluationNode[]; +} + +/** + * A logical OR node - at least one child must pass. + */ +export interface AnyNode { + type: "any"; + children: EvaluationNode[]; +} + +/** + * A logical NOT node - inverts the child's result. + */ +export interface NotNode { + type: "not"; + child: EvaluationNode; +} + +/** + * A reason explaining a policy decision. + */ +export interface DecisionReason { + code: string; + nodeId: string; + message?: string; +} + +/** + * The complete explanation of a policy decision. + */ +export interface DecisionExplanation { + allowed: boolean; + reasons: DecisionReason[]; +} + +/** + * Configuration options for the explanation engine. + */ +export interface ExplanationOptions { + /** + * Maximum allowed depth of the evaluation tree. + * @default 50 + */ + maxDepth?: number; + + /** + * Maximum allowed number of nodes in the evaluation tree. + * @default 1000 + */ + maxNodes?: number; +} + +// ============================================================================ +// Error Types +// ============================================================================ + +/** + * Error thrown when the evaluation tree is malformed or exceeds limits. + */ +export class ExplanationError extends Error { + constructor(message: string) { + super(message); + this.name = "ExplanationError"; + } +} + +// ============================================================================ +// Default Configuration +// ============================================================================ + +const DEFAULT_MAX_DEPTH = 50; +const DEFAULT_MAX_NODES = 1000; + +// ============================================================================ +// Reason Code Generation +// ============================================================================ + +/** + * Generates stable reason codes based on node type and outcome. + */ +function generateReasonCode(nodeType: string, passed: boolean): string { + const prefix = passed ? "PASS" : "FAIL"; + const typeMap: Record = { + condition: "COND", + all: "ALL", + any: "ANY", + not: "NOT" + }; + return `${prefix}_${typeMap[nodeType] || nodeType.toUpperCase()}`; +} + +// ============================================================================ +// Tree Validation +// ============================================================================ + +/** + * Validates the evaluation tree structure and limits. + */ +function validateTree( + node: EvaluationNode, + depth: number, + nodeCount: { value: number }, + options: Required +): void { + const maxDepth = options.maxDepth; + const maxNodes = options.maxNodes; + + if (depth >= maxDepth) { + throw new ExplanationError( + `Evaluation tree exceeds maximum depth of ${maxDepth}` + ); + } + + nodeCount.value++; + if (nodeCount.value > maxNodes) { + throw new ExplanationError( + `Evaluation tree exceeds maximum node count of ${maxNodes}` + ); + } + + switch (node.type) { + case "condition": + if (typeof node.id !== "string" || node.id.length === 0) { + throw new ExplanationError("Condition node must have a non-empty id"); + } + if (typeof node.passed !== "boolean") { + throw new ExplanationError("Condition node must have a boolean passed field"); + } + break; + + case "all": + case "any": + if (!Array.isArray(node.children)) { + throw new ExplanationError( + `${node.type} node must have a children array` + ); + } + for (const child of node.children) { + validateTree(child, depth + 1, nodeCount, options); + } + break; + + case "not": + if (!node.child) { + throw new ExplanationError("Not node must have a child"); + } + validateTree(node.child, depth + 1, nodeCount, options); + break; + + default: + throw new ExplanationError( + `Unknown node type: ${(node as { type: string }).type}` + ); + } +} + +// ============================================================================ +// Outcome Calculation +// ============================================================================ + +/** + * Calculates the boolean outcome of an evaluation node. + */ +function calculateOutcome(node: EvaluationNode): boolean { + switch (node.type) { + case "condition": + return node.passed; + + case "all": + return node.children.every((child) => calculateOutcome(child)); + + case "any": + return node.children.some((child) => calculateOutcome(child)); + + case "not": + return !calculateOutcome(node.child); + } +} + +// ============================================================================ +// Reason Extraction +// ============================================================================ + +/** + * Extracts reasons from an evaluation tree. + * For failures, focuses on the most relevant failing conditions. + */ +function extractReasons( + node: EvaluationNode, + parentPassed: boolean | null, + reasons: DecisionReason[], + path: string[] +): void { + const nodeId = isConditionNode(node) ? node.id : path.join("."); + const outcome = calculateOutcome(node); + + switch (node.type) { + case "condition": { + const code = generateReasonCode("condition", outcome); + reasons.push({ + code, + nodeId, + message: node.reason + }); + break; + } + + case "all": { + if (!outcome) { + // For ALL failures, report all failing children + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + const childOutcome = calculateOutcome(child); + if (!childOutcome) { + extractReasons(child, false, reasons, [...path, String(i)]); + } + } + } else { + // For ALL passes, report that all children passed + reasons.push({ + code: generateReasonCode("all", true), + nodeId, + message: "All conditions passed" + }); + } + break; + } + + case "any": { + if (!outcome) { + // For ANY failures, report that no child passed and show child reasons + reasons.push({ + code: generateReasonCode("any", false), + nodeId, + message: "No conditions passed" + }); + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + extractReasons(child, false, reasons, [...path, String(i)]); + } + } else { + // For ANY passes, report the passing child + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + if (calculateOutcome(child)) { + extractReasons(child, true, reasons, [...path, String(i)]); + break; // Only report the first passing child + } + } + } + break; + } + + case "not": { + const childOutcome = calculateOutcome(node.child); + reasons.push({ + code: generateReasonCode("not", outcome), + nodeId, + message: outcome + ? "Negated condition failed" + : "Negated condition passed" + }); + // Always include child details for NOT nodes for clarity + extractReasons(node.child, outcome, reasons, [...path, "0"]); + break; + } + } +} + +// ============================================================================ +// Main Explanation Function +// ============================================================================ + +/** + * Explains a policy decision based on an evaluation tree. + * + * @param node - The root of the evaluation tree + * @param options - Configuration options + * @returns A structured decision explanation + * @throws {ExplanationError} If the tree is malformed or exceeds limits + */ +export function explainDecision( + node: EvaluationNode, + options: ExplanationOptions = {} +): DecisionExplanation { + const resolvedOptions: Required = { + maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH, + maxNodes: options.maxNodes ?? DEFAULT_MAX_NODES + }; + + // Validate the tree structure and limits + const nodeCount = { value: 0 }; + validateTree(node, 0, nodeCount, resolvedOptions); + + // Calculate the overall outcome + const allowed = calculateOutcome(node); + + // Extract reasons + const reasons: DecisionReason[] = []; + extractReasons(node, null, reasons, []); + + // Ensure deterministic ordering by sorting reasons + // For NOT nodes, ensure the NOT reason comes before child reason + reasons.sort((a, b) => { + // Prioritize NOT codes over COND codes when codes are different + if (a.code.includes('NOT') && !b.code.includes('NOT')) { + return -1; + } + if (!a.code.includes('NOT') && b.code.includes('NOT')) { + return 1; + } + // Sort by code first, then by nodeId + if (a.code !== b.code) { + return a.code.localeCompare(b.code); + } + return a.nodeId.localeCompare(b.nodeId); + }); + + return { + allowed, + reasons + }; +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Creates a condition node. + */ +export function condition( + id: string, + passed: boolean, + reason?: string +): ConditionNode { + return { type: "condition", id, passed, reason }; +} + +/** + * Creates an ALL node (logical AND). + */ +export function all(...children: EvaluationNode[]): AllNode { + return { type: "all", children }; +} + +/** + * Creates an ANY node (logical OR). + */ +export function any(...children: EvaluationNode[]): AnyNode { + return { type: "any", children }; +} + +/** + * Creates a NOT node (logical negation). + */ +export function not(child: EvaluationNode): NotNode { + return { type: "not", child }; +} + +// ============================================================================ +// Type Guards +// ============================================================================ + +/** + * Type guard for condition nodes. + */ +export function isConditionNode(node: EvaluationNode): node is ConditionNode { + return node.type === "condition"; +} + +/** + * Type guard for ALL nodes. + */ +export function isAllNode(node: EvaluationNode): node is AllNode { + return node.type === "all"; +} + +/** + * Type guard for ANY nodes. + */ +export function isAnyNode(node: EvaluationNode): node is AnyNode { + return node.type === "any"; +} + +/** + * Type guard for NOT nodes. + */ +export function isNotNode(node: EvaluationNode): node is NotNode { + return node.type === "not"; +} diff --git a/packages/policy-explanation/tsconfig.json b/packages/policy-explanation/tsconfig.json new file mode 100644 index 0000000..c99ec7b --- /dev/null +++ b/packages/policy-explanation/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/retry-policy/package.json b/packages/retry-policy/package.json new file mode 100644 index 0000000..defd78b --- /dev/null +++ b/packages/retry-policy/package.json @@ -0,0 +1,19 @@ +{ + "name": "@guildpass/retry-policy", + "version": "2.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "node --test dist/**/*.test.js" + } +} diff --git a/packages/retry-policy/src/index.test.ts b/packages/retry-policy/src/index.test.ts new file mode 100644 index 0000000..c3e3e5b --- /dev/null +++ b/packages/retry-policy/src/index.test.ts @@ -0,0 +1,863 @@ +/** + * Unit tests for the Retry Policy Engine + */ + +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; +import { + retry, + calculateBackoff, + RetryExhaustedError, + retryableByType, + retryableByCode, + retryableIf, + type RetryOptions, + type RetryMetadata +} from "./index.js"; + +describe("Retry Policy Engine", () => { + describe("Backoff Calculation", () => { + it("should calculate exponential backoff correctly", () => { + // attempt 1: 1000 * 2^0 = 1000 + assert.strictEqual(calculateBackoff(1, 1000, 30000, 2, { enabled: false }), 1000); + // attempt 2: 1000 * 2^1 = 2000 + assert.strictEqual(calculateBackoff(2, 1000, 30000, 2, { enabled: false }), 2000); + // attempt 3: 1000 * 2^2 = 4000 + assert.strictEqual(calculateBackoff(3, 1000, 30000, 2, { enabled: false }), 4000); + // attempt 4: 1000 * 2^3 = 8000 + assert.strictEqual(calculateBackoff(4, 1000, 30000, 2, { enabled: false }), 8000); + }); + + it("should cap delay at maximum", () => { + // With maxDelay of 5000, attempt 4 should be capped at 5000 + assert.strictEqual(calculateBackoff(4, 1000, 5000, 2, { enabled: false }), 5000); + // Even higher attempts should stay capped + assert.strictEqual(calculateBackoff(10, 1000, 5000, 2, { enabled: false }), 5000); + }); + + it("should apply jitter when enabled", () => { + const randomValues = [0.5, 0.25, 0.75]; + let index = 0; + const mockRandom = () => randomValues[index++]; + + // With jitter, delay should be: cappedDelay * random + const delay1 = calculateBackoff(1, 1000, 30000, 2, { enabled: true, random: mockRandom }); + assert.strictEqual(delay1, 1000 * 0.5); // 500 + + const delay2 = calculateBackoff(2, 1000, 30000, 2, { enabled: true, random: mockRandom }); + assert.strictEqual(delay2, 2000 * 0.25); // 500 + + const delay3 = calculateBackoff(3, 1000, 30000, 2, { enabled: true, random: mockRandom }); + assert.strictEqual(delay3, 4000 * 0.75); // 3000 + }); + + it("should not apply jitter when disabled", () => { + const delay = calculateBackoff(2, 1000, 30000, 2, { enabled: false }); + assert.strictEqual(delay, 2000); + }); + + it("should use default values when not provided", () => { + const delay = calculateBackoff(2); + // Default: initialDelay=1000, maxDelay=30000, multiplier=2 + // With jitter enabled, should be between 0 and 2000 + assert.ok(delay >= 0 && delay <= 2000); + }); + + it("should handle custom multiplier", () => { + // With multiplier 3: attempt 2 = 1000 * 3^1 = 3000 + assert.strictEqual(calculateBackoff(2, 1000, 30000, 3, { enabled: false }), 3000); + }); + }); + + describe("Successful Operations", () => { + it("should return immediately on success without retries", async () => { + let callCount = 0; + const operation = async () => { + callCount++; + return "success"; + }; + + const result = await retry(operation); + + assert.strictEqual(result, "success"); + assert.strictEqual(callCount, 1); + }); + + it("should pass metadata to operation", async () => { + const metadataValues: RetryMetadata[] = []; + const operation = async (metadata: RetryMetadata) => { + metadataValues.push(metadata); + return "success"; + }; + + await retry(operation); + + assert.strictEqual(metadataValues.length, 1); + assert.strictEqual(metadataValues[0].attempt, 1); + assert.strictEqual(metadataValues[0].totalAttempts, 1); + }); + }); + + describe("Retry on Failure", () => { + it("should retry on failure up to max attempts", async () => { + let callCount = 0; + const operation = async () => { + callCount++; + if (callCount < 3) { + throw new Error("Temporary failure"); + } + return "success"; + }; + + const result = await retry(operation, { maxAttempts: 5 }); + + assert.strictEqual(result, "success"); + assert.strictEqual(callCount, 3); + }); + + it("should throw RetryExhaustedError when attempts exhausted", async () => { + const operation = async () => { + throw new Error("Persistent failure"); + }; + + await assert.rejects( + async () => await retry(operation, { maxAttempts: 3 }), + (error: Error) => { + assert.ok(error instanceof RetryExhaustedError); + assert.strictEqual(error.attempts, 3); + assert.ok(error.cause instanceof Error); + assert.strictEqual((error.cause as Error).message, "Persistent failure"); + return true; + } + ); + }); + + it("should count first execution as an attempt", async () => { + let callCount = 0; + const operation = async () => { + callCount++; + throw new Error("Failure"); + }; + + await assert.rejects( + async () => await retry(operation, { maxAttempts: 1 }), + (error: Error) => { + assert.ok(error instanceof RetryExhaustedError); + assert.strictEqual(error.attempts, 1); + assert.strictEqual(callCount, 1); + return true; + } + ); + }); + + it("should respect custom maxAttempts", async () => { + let callCount = 0; + const operation = async () => { + callCount++; + throw new Error("Failure"); + }; + + await assert.rejects( + async () => await retry(operation, { maxAttempts: 5 }), + (error: Error) => { + assert.ok(error instanceof RetryExhaustedError); + assert.strictEqual(error.attempts, 5); + assert.strictEqual(callCount, 5); + return true; + } + ); + }); + }); + + describe("Non-Retryable Errors", () => { + it("should not retry non-retryable errors", async () => { + let callCount = 0; + const operation = async () => { + callCount++; + throw new Error("Permanent failure"); + }; + + const isRetryable = (error: unknown) => { + return !(error instanceof Error && error.message === "Permanent failure"); + }; + + await assert.rejects( + async () => await retry(operation, { maxAttempts: 5, isRetryable }), + (error: Error) => { + // Should throw the original error, not RetryExhaustedError + assert.strictEqual(error.message, "Permanent failure"); + assert.strictEqual(callCount, 1); + return true; + } + ); + }); + + it("should retry retryable errors but not non-retryable", async () => { + let callCount = 0; + const operation = async () => { + callCount++; + if (callCount === 1) { + throw new Error("Temporary failure"); + } else if (callCount === 2) { + throw new Error("Permanent failure"); + } + return "success"; + }; + + const isRetryable = (error: unknown) => { + return !(error instanceof Error && error.message === "Permanent failure"); + }; + + await assert.rejects( + async () => await retry(operation, { maxAttempts: 5, isRetryable }), + (error: Error) => { + assert.strictEqual(error.message, "Permanent failure"); + assert.strictEqual(callCount, 2); // First error retried, second not + return true; + } + ); + }); + }); + + describe("Exponential Backoff", () => { + it("should use exponential backoff between retries", async () => { + const delays: number[] = []; + let callCount = 0; + + const operation = async () => { + callCount++; + if (callCount < 3) { + throw new Error("Failure"); + } + return "success"; + }; + + const startTime = Date.now(); + await retry(operation, { + maxAttempts: 3, + initialDelay: 100, + jitter: { enabled: false } + }); + const elapsed = Date.now() - startTime; + + // Should have waited: 100ms (between attempt 1 and 2) + // Total time should be at least 100ms + assert.ok(elapsed >= 90); // Allow some tolerance + }); + + it("should respect custom initial delay", async () => { + let callCount = 0; + const operation = async () => { + callCount++; + if (callCount < 2) { + throw new Error("Failure"); + } + return "success"; + }; + + const startTime = Date.now(); + await retry(operation, { + maxAttempts: 2, + initialDelay: 50, + jitter: { enabled: false } + }); + const elapsed = Date.now() - startTime; + + assert.ok(elapsed >= 45); // At least 50ms delay + }); + + it("should respect custom backoff multiplier", async () => { + let callCount = 0; + const operation = async () => { + callCount++; + if (callCount < 3) { + throw new Error("Failure"); + } + return "success"; + }; + + const startTime = Date.now(); + await retry(operation, { + maxAttempts: 3, + initialDelay: 50, + backoffMultiplier: 3, + jitter: { enabled: false } + }); + const elapsed = Date.now() - startTime; + + // Should have waited: 50 * 3 = 150ms + assert.ok(elapsed >= 140); + }); + + it("should cap delay at maxDelay", async () => { + let callCount = 0; + const operation = async () => { + callCount++; + if (callCount < 3) { + throw new Error("Failure"); + } + return "success"; + }; + + const startTime = Date.now(); + await retry(operation, { + maxAttempts: 3, + initialDelay: 10, + maxDelay: 50, + backoffMultiplier: 10, + jitter: { enabled: false } + }); + const elapsed = Date.now() - startTime; + + // Should be capped at 50ms (10 * 10 = 100, but capped at 50) + assert.ok(elapsed >= 45); + assert.ok(elapsed < 200); // Allow tolerance for operation execution time + }); + }); + + describe("Jitter", () => { + it("should apply jitter by default", async () => { + let callCount = 0; + const operation = async () => { + callCount++; + if (callCount < 2) { + throw new Error("Failure"); + } + return "success"; + }; + + // With jitter, delay should vary + const delays: number[] = []; + for (let i = 0; i < 5; i++) { + callCount = 0; + const startTime = Date.now(); + await retry(operation, { + maxAttempts: 2, + initialDelay: 100 + }); + delays.push(Date.now() - startTime); + } + + // At least some variation should occur (though not guaranteed) + // This is more of a sanity check + assert.ok(delays.every(d => d >= 0)); + }); + + it("should use deterministic random when provided", async () => { + let callCount = 0; + const operation = async () => { + callCount++; + if (callCount < 2) { + throw new Error("Failure"); + } + return "success"; + }; + + let randomCallCount = 0; + const mockRandom = () => { + randomCallCount++; + return 0.5; + }; + + await retry(operation, { + maxAttempts: 2, + initialDelay: 100, + jitter: { enabled: true, random: mockRandom } + }); + + assert.ok(randomCallCount > 0); + }); + + it("should allow disabling jitter", async () => { + let callCount = 0; + const operation = async () => { + callCount++; + if (callCount < 2) { + throw new Error("Failure"); + } + return "success"; + }; + + const startTime = Date.now(); + await retry(operation, { + maxAttempts: 2, + initialDelay: 50, + jitter: { enabled: false } + }); + const elapsed = Date.now() - startTime; + + // Without jitter, should be very close to 50ms + assert.ok(elapsed >= 45); + assert.ok(elapsed < 200); // Allow tolerance for operation execution time + }); + }); + + describe("Cancellation", () => { + it("should stop when signal is aborted before operation", async () => { + const controller = new AbortController(); + controller.abort(); + + const operation = async () => { + return "success"; + }; + + await assert.rejects( + async () => await retry(operation, { signal: controller.signal }), + (error: Error) => { + assert.strictEqual(error.name, "AbortError"); + return true; + } + ); + }); + + it("should stop when signal is aborted during retry", async () => { + const controller = new AbortController(); + let callCount = 0; + + const operation = async () => { + callCount++; + if (callCount === 1) { + // Abort after first attempt - use longer delay to ensure it happens during retry wait + setTimeout(() => controller.abort(), 50); + throw new Error("Failure"); + } + return "success"; + }; + + await assert.rejects( + async () => await retry(operation, { + signal: controller.signal, + maxAttempts: 5, + initialDelay: 200 // Longer delay to ensure abort happens during wait + }), + (error: Error) => { + assert.strictEqual(error.name, "AbortError"); + assert.strictEqual(callCount, 1); + return true; + } + ); + }); + + it("should clean up timers on abort", async () => { + const controller = new AbortController(); + let callCount = 0; + + const operation = async () => { + callCount++; + throw new Error("Failure"); + }; + + // Abort immediately + controller.abort(); + + await assert.rejects( + async () => await retry(operation, { + signal: controller.signal, + maxAttempts: 5, + initialDelay: 10000 // Long delay + }), + (error: Error) => { + assert.strictEqual(error.name, "AbortError"); + // Should not wait for the long delay + assert.strictEqual(callCount, 0); + return true; + } + ); + }); + }); + + describe("Retry Callback", () => { + it("should call onRetry callback before each retry", async () => { + const retryCalls: number[] = []; + let callCount = 0; + + const operation = async () => { + callCount++; + if (callCount < 3) { + throw new Error("Failure"); + } + return "success"; + }; + + const onRetry = (attempt: number, error: unknown) => { + retryCalls.push(attempt); + }; + + await retry(operation, { + maxAttempts: 5, + onRetry + }); + + // Should have called onRetry twice (after attempt 1 and attempt 2) + assert.deepStrictEqual(retryCalls, [1, 2]); + }); + + it("should pass error to onRetry callback", async () => { + const errors: unknown[] = []; + let callCount = 0; + + const operation = async () => { + callCount++; + throw new Error(`Failure ${callCount}`); + }; + + const onRetry = (attempt: number, error: unknown) => { + errors.push(error); + }; + + await assert.rejects( + async () => await retry(operation, { + maxAttempts: 3, + onRetry + }) + ); + + assert.strictEqual(errors.length, 2); + assert.ok(errors[0] instanceof Error); + assert.strictEqual((errors[0] as Error).message, "Failure 1"); + assert.strictEqual((errors[1] as Error).message, "Failure 2"); + }); + }); + + describe("Validation", () => { + it("should reject maxAttempts less than 1", async () => { + const operation = async () => "success"; + + await assert.rejects( + async () => await retry(operation, { maxAttempts: 0 }), + (error: Error) => { + assert.strictEqual(error.name, "RangeError"); + assert.ok(error.message.includes("maxAttempts")); + return true; + } + ); + }); + + it("should reject negative initialDelay", async () => { + const operation = async () => "success"; + + await assert.rejects( + async () => await retry(operation, { initialDelay: -1 }), + (error: Error) => { + assert.strictEqual(error.name, "RangeError"); + assert.ok(error.message.includes("initialDelay")); + return true; + } + ); + }); + + it("should reject negative maxDelay", async () => { + const operation = async () => "success"; + + await assert.rejects( + async () => await retry(operation, { maxDelay: -1 }), + (error: Error) => { + assert.strictEqual(error.name, "RangeError"); + assert.ok(error.message.includes("maxDelay")); + return true; + } + ); + }); + + it("should reject backoffMultiplier less than 1", async () => { + const operation = async () => "success"; + + await assert.rejects( + async () => await retry(operation, { backoffMultiplier: 0.5 }), + (error: Error) => { + assert.strictEqual(error.name, "RangeError"); + assert.ok(error.message.includes("backoffMultiplier")); + return true; + } + ); + }); + + it("should reject initialDelay greater than maxDelay", async () => { + const operation = async () => "success"; + + await assert.rejects( + async () => await retry(operation, { initialDelay: 1000, maxDelay: 500 }), + (error: Error) => { + assert.strictEqual(error.name, "RangeError"); + assert.ok(error.message.includes("initialDelay")); + return true; + } + ); + }); + }); + + describe("Utility Functions", () => { + describe("retryableByType", () => { + class NetworkError extends Error { + constructor(message: string) { + super(message); + this.name = "NetworkError"; + } + } + + class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } + } + + it("should classify errors by type", () => { + const isRetryable = retryableByType([NetworkError]); + + assert.strictEqual(isRetryable(new NetworkError("Timeout")), true); + assert.strictEqual(isRetryable(new ValidationError("Invalid")), false); + assert.strictEqual(isRetryable(new Error("Generic")), false); + }); + + it("should work with retry", async () => { + let callCount = 0; + const operation = async () => { + callCount++; + if (callCount < 2) { + throw new NetworkError("Timeout"); + } + return "success"; + }; + + const isRetryable = retryableByType([NetworkError]); + const result = await retry(operation, { + maxAttempts: 3, + isRetryable + }); + + assert.strictEqual(result, "success"); + assert.strictEqual(callCount, 2); + }); + + it("should not retry non-matching types", async () => { + let callCount = 0; + const operation = async () => { + callCount++; + throw new ValidationError("Invalid"); + }; + + const isRetryable = retryableByType([NetworkError]); + + await assert.rejects( + async () => await retry(operation, { + maxAttempts: 3, + isRetryable + }), + (error: Error) => { + assert.strictEqual(error.name, "ValidationError"); + assert.strictEqual(callCount, 1); + return true; + } + ); + }); + }); + + describe("retryableByCode", () => { + it("should classify errors by code", () => { + const isRetryable = retryableByCode(["ETIMEDOUT", "ECONNRESET"]); + + const error1 = new Error("Timeout"); + (error1 as any).code = "ETIMEDOUT"; + assert.strictEqual(isRetryable(error1), true); + + const error2 = new Error("Connection reset"); + (error2 as any).code = "ECONNRESET"; + assert.strictEqual(isRetryable(error2), true); + + const error3 = new Error("Not found"); + (error3 as any).code = "ENOTFOUND"; + assert.strictEqual(isRetryable(error3), false); + }); + + it("should fallback to message if code not present", () => { + const isRetryable = retryableByCode(["ETIMEDOUT"]); + + const error = new Error("ETIMEDOUT"); + assert.strictEqual(isRetryable(error), true); + + const error2 = new Error("Something else"); + assert.strictEqual(isRetryable(error2), false); + }); + + it("should return false for non-Error objects", () => { + const isRetryable = retryableByCode(["ETIMEDOUT"]); + assert.strictEqual(isRetryable("string error"), false); + assert.strictEqual(isRetryable(null), false); + assert.strictEqual(isRetryable(undefined), false); + }); + }); + + describe("retryableIf", () => { + it("should use custom predicate", () => { + const isRetryable = retryableIf((error: unknown) => { + return error instanceof Error && error.message.includes("temporary"); + }); + + assert.strictEqual(isRetryable(new Error("temporary failure")), true); + assert.strictEqual(isRetryable(new Error("permanent failure")), false); + }); + + it("should work with complex predicates", () => { + const isRetryable = retryableIf((error: unknown) => { + if (error instanceof Error) { + const is5xx = (error as any).status >= 500; + const isNetworkError = error.message.includes("network"); + return is5xx || isNetworkError; + } + return false; + }); + + const error1 = new Error("Server error"); + (error1 as any).status = 500; + assert.strictEqual(isRetryable(error1), true); + + const error2 = new Error("network timeout"); + assert.strictEqual(isRetryable(error2), true); + + const error3 = new Error("Client error"); + (error3 as any).status = 400; + assert.strictEqual(isRetryable(error3), false); + }); + }); + }); + + describe("Edge Cases", () => { + it("should handle zero initialDelay", async () => { + let callCount = 0; + const operation = async () => { + callCount++; + if (callCount < 2) { + throw new Error("Failure"); + } + return "success"; + }; + + const result = await retry(operation, { + maxAttempts: 2, + initialDelay: 0, + jitter: { enabled: false } + }); + + assert.strictEqual(result, "success"); + assert.strictEqual(callCount, 2); + }); + + it("should handle operation returning undefined", async () => { + const operation = async () => { + return undefined; + }; + + const result = await retry(operation); + assert.strictEqual(result, undefined); + }); + + it("should handle operation returning null", async () => { + const operation = async () => { + return null; + }; + + const result = await retry(operation); + assert.strictEqual(result, null); + }); + + it("should handle operation throwing non-Error", async () => { + let callCount = 0; + const operation = async () => { + callCount++; + if (callCount < 2) { + throw "string error"; + } + return "success"; + }; + + const result = await retry(operation, { maxAttempts: 3 }); + assert.strictEqual(result, "success"); + assert.strictEqual(callCount, 2); + }); + + it("should preserve error context in RetryExhaustedError", async () => { + class CustomError extends Error { + constructor(message: string, public code: string) { + super(message); + this.name = "CustomError"; + } + } + + const operation = async () => { + throw new CustomError("Custom failure", "ERR_123"); + }; + + await assert.rejects( + async () => await retry(operation, { maxAttempts: 2 }), + (error: Error) => { + assert.ok(error instanceof RetryExhaustedError); + assert.ok(error.cause instanceof CustomError); + assert.strictEqual((error.cause as CustomError).code, "ERR_123"); + return true; + } + ); + }); + + it("should handle very large maxAttempts", async () => { + let callCount = 0; + const operation = async () => { + callCount++; + if (callCount < 2) { + throw new Error("Failure"); + } + return "success"; + }; + + const result = await retry(operation, { + maxAttempts: 1000, + initialDelay: 0 + }); + + assert.strictEqual(result, "success"); + assert.strictEqual(callCount, 2); + }); + }); + + describe("Timer Cleanup", () => { + it("should not leak timers on success", async () => { + const operation = async () => { + return "success"; + }; + + // This test mainly ensures no errors are thrown + await retry(operation, { maxAttempts: 5 }); + assert.ok(true); + }); + + it("should not leak timers on exhaustion", async () => { + const operation = async () => { + throw new Error("Failure"); + }; + + await assert.rejects( + async () => await retry(operation, { + maxAttempts: 2, + initialDelay: 10 + }) + ); + assert.ok(true); + }); + + it("should not leak timers on non-retryable error", async () => { + const operation = async () => { + throw new Error("Permanent"); + }; + + const isRetryable = () => false; + + await assert.rejects( + async () => await retry(operation, { + maxAttempts: 5, + isRetryable + }) + ); + assert.ok(true); + }); + }); +}); diff --git a/packages/retry-policy/src/index.ts b/packages/retry-policy/src/index.ts new file mode 100644 index 0000000..a182f38 --- /dev/null +++ b/packages/retry-policy/src/index.ts @@ -0,0 +1,399 @@ +/** + * Retry Policy Engine + * + * A generic asynchronous retry engine supporting exponential backoff, jitter, + * cancellation, and caller-defined retry classification. + * + * This is a standalone resilience primitive with no dependencies on + * Stellar RPC, HTTP clients, Redis, Prisma, or other services. + */ + +// ============================================================================ +// Type Definitions +// ============================================================================ + +/** + * Configuration for jitter injection into backoff delays. + */ +export interface JitterConfig { + /** + * Whether to apply jitter to backoff delays. + * @default true + */ + enabled?: boolean; + + /** + * Random number generator for deterministic jitter in tests. + * If not provided, uses Math.random(). + */ + random?: () => number; +} + +/** + * Configuration for retry behavior. + */ +export interface RetryOptions { + /** + * Maximum number of attempts (including the first execution). + * @default 3 + */ + maxAttempts?: number; + + /** + * Initial delay before the first retry in milliseconds. + * @default 1000 + */ + initialDelay?: number; + + /** + * Maximum delay cap in milliseconds. + * @default 30000 + */ + maxDelay?: number; + + /** + * Multiplier for exponential backoff. + * @default 2 + */ + backoffMultiplier?: number; + + /** + * Jitter configuration to prevent retry storms. + * @default { enabled: true } + */ + jitter?: JitterConfig; + + /** + * AbortSignal for cancellation. + */ + signal?: AbortSignal; + + /** + * Predicate to determine if an error is retryable. + * If not provided, all errors are considered retryable. + */ + isRetryable?: (error: unknown) => boolean; + + /** + * Callback invoked before each retry attempt. + * Receives the attempt number (1-indexed) and the error that caused the retry. + */ + onRetry?: (attempt: number, error: unknown) => void; +} + +/** + * Metadata about retry attempts. + */ +export interface RetryMetadata { + /** + * The attempt number (1-indexed). + */ + attempt: number; + + /** + * Total number of attempts made. + */ + totalAttempts: number; +} + +/** + * Error thrown when retry attempts are exhausted. + */ +export class RetryExhaustedError extends Error { + /** + * The error that caused the final failure. + */ + readonly cause: unknown; + + /** + * Number of attempts made. + */ + readonly attempts: number; + + constructor(message: string, cause: unknown, attempts: number) { + super(message); + this.name = "RetryExhaustedError"; + this.cause = cause; + this.attempts = attempts; + } +} + +// ============================================================================ +// Default Configuration +// ============================================================================ + +const DEFAULT_MAX_ATTEMPTS = 3; +const DEFAULT_INITIAL_DELAY = 1000; +const DEFAULT_MAX_DELAY = 30000; +const DEFAULT_BACKOFF_MULTIPLIER = 2; + +// ============================================================================ +// Backoff Calculation +// ============================================================================ + +/** + * Calculates exponential backoff delay for a given attempt. + * + * @param attempt - The attempt number (1-indexed) + * @param initialDelay - Initial delay in milliseconds + * @param maxDelay - Maximum delay cap in milliseconds + * @param multiplier - Backoff multiplier + * @param jitter - Jitter configuration + * @returns Delay in milliseconds + */ +export function calculateBackoff( + attempt: number, + initialDelay: number = DEFAULT_INITIAL_DELAY, + maxDelay: number = DEFAULT_MAX_DELAY, + multiplier: number = DEFAULT_BACKOFF_MULTIPLIER, + jitter: JitterConfig = { enabled: true } +): number { + // Calculate exponential backoff: initialDelay * (multiplier ^ (attempt - 1)) + const exponentialDelay = initialDelay * Math.pow(multiplier, attempt - 1); + + // Cap at maximum delay + const cappedDelay = Math.min(exponentialDelay, maxDelay); + + // Apply jitter if enabled + if (jitter.enabled !== false) { + const random = jitter.random || Math.random; + // Full jitter: random value between 0 and cappedDelay + return cappedDelay * random(); + } + + return cappedDelay; +} + +// ============================================================================ +// Retry Classification +// ============================================================================ + +/** + * Default retry classification - considers all errors retryable. + */ +function defaultIsRetryable(_error: unknown): boolean { + return true; +} + +// ============================================================================ +// Delay with Cancellation +// ============================================================================ + +/** + * Creates a delay promise that respects AbortSignal. + * + * @param ms - Delay in milliseconds + * @param signal - Optional AbortSignal for cancellation + * @returns Promise that resolves after delay or rejects on abort + */ +function delay(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } + + const timeout = setTimeout(() => { + resolve(); + cleanup(); + }, ms); + + const onAbort = () => { + clearTimeout(timeout); + reject(new DOMException("Aborted", "AbortError")); + cleanup(); + }; + + const cleanup = () => { + signal?.removeEventListener("abort", onAbort); + }; + + signal?.addEventListener("abort", onAbort); + }); +} + +// ============================================================================ +// Validation +// ============================================================================ + +/** + * Validates retry configuration. + */ +function validateOptions(options: RetryOptions): void { + const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; + const initialDelay = options.initialDelay ?? DEFAULT_INITIAL_DELAY; + const maxDelay = options.maxDelay ?? DEFAULT_MAX_DELAY; + const multiplier = options.backoffMultiplier ?? DEFAULT_BACKOFF_MULTIPLIER; + + if (maxAttempts < 1) { + throw new RangeError("maxAttempts must be at least 1"); + } + + if (initialDelay < 0) { + throw new RangeError("initialDelay must be non-negative"); + } + + if (maxDelay < 0) { + throw new RangeError("maxDelay must be non-negative"); + } + + if (multiplier < 1) { + throw new RangeError("backoffMultiplier must be at least 1"); + } + + if (initialDelay > maxDelay) { + throw new RangeError("initialDelay cannot exceed maxDelay"); + } +} + +// ============================================================================ +// Main Retry Function +// ============================================================================ + +/** + * Retries an async operation with configurable backoff and jitter. + * + * @param operation - Async operation to retry + * @param options - Retry configuration + * @returns Promise that resolves with operation result or rejects with RetryExhaustedError + * + * @example + * ```ts + * const result = await retry( + * async () => fetch(url), + * { maxAttempts: 5, initialDelay: 1000 } + * ); + * ``` + */ +export async function retry( + operation: (metadata: RetryMetadata) => Promise, + options: RetryOptions = {} +): Promise { + validateOptions(options); + + const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; + const initialDelay = options.initialDelay ?? DEFAULT_INITIAL_DELAY; + const maxDelay = options.maxDelay ?? DEFAULT_MAX_DELAY; + const multiplier = options.backoffMultiplier ?? DEFAULT_BACKOFF_MULTIPLIER; + const jitter = options.jitter ?? { enabled: true }; + const isRetryable = options.isRetryable ?? defaultIsRetryable; + + let lastError: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + // Check for cancellation before each attempt + if (options.signal?.aborted) { + throw new DOMException("Aborted", "AbortError"); + } + + const metadata: RetryMetadata = { + attempt, + totalAttempts: attempt + }; + + try { + // Execute the operation + const result = await operation(metadata); + return result; + } catch (error) { + lastError = error; + + // Check if error is retryable + if (!isRetryable(error)) { + throw error; + } + + // If this was the last attempt, throw exhausted error + if (attempt === maxAttempts) { + throw new RetryExhaustedError( + `Operation failed after ${maxAttempts} attempts`, + lastError, + maxAttempts + ); + } + + // Invoke onRetry callback if provided + options.onRetry?.(attempt, error); + + // Calculate backoff and wait before next attempt + const backoffDelay = calculateBackoff( + attempt + 1, + initialDelay, + maxDelay, + multiplier, + jitter + ); + + await delay(backoffDelay, options.signal); + } + } + + // This should never be reached, but TypeScript needs it + throw new RetryExhaustedError( + "Operation failed", + lastError, + maxAttempts + ); +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Creates a retry predicate that classifies errors by type. + * + * @param retryableTypes - Array of error constructors that are retryable + * @returns Predicate function for isRetryable option + * + * @example + * ```ts + * const isNetworkErrorRetryable = retryableByType([NetworkError, TimeoutError]); + * await retry(operation, { isRetryable: isNetworkErrorRetryable }); + * ``` + */ +export function retryableByType( + retryableTypes: Array Error> +): (error: unknown) => boolean { + return (error: unknown) => { + return retryableTypes.some( + (Type) => error instanceof Type + ); + }; +} + +/** + * Creates a retry predicate that classifies errors by error code/message. + * + * @param retryableCodes - Array of error codes or messages that are retryable + * @returns Predicate function for isRetryable option + * + * @example + * ```ts + * const isRetryableByCode = retryableByCode(['ETIMEDOUT', 'ECONNRESET']); + * await retry(operation, { isRetryable: isRetryableByCode }); + * ``` + */ +export function retryableByCode( + retryableCodes: string[] +): (error: unknown) => boolean { + return (error: unknown) => { + if (error instanceof Error) { + const errorCode = (error as any).code; + return retryableCodes.includes(errorCode || error.message); + } + return false; + }; +}; + +/** + * Creates a retry predicate that classifies errors by custom predicate. + * + * @param predicate - Custom predicate function + * @returns Predicate function for isRetryable option + */ +export function retryableIf( + predicate: (error: unknown) => boolean +): (error: unknown) => boolean { + return predicate; +} diff --git a/packages/retry-policy/tsconfig.json b/packages/retry-policy/tsconfig.json new file mode 100644 index 0000000..c99ec7b --- /dev/null +++ b/packages/retry-policy/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a50ed3..799140b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,8 +77,12 @@ importers: specifier: ^1.2.1 version: 1.6.1(@types/node@20.19.43) + packages/payload-migrations: {} + packages/permission-expression: {} + packages/policy-explanation: {} + packages/priority-queue: devDependencies: typescript: @@ -99,6 +103,8 @@ importers: specifier: ^1.2.1 version: 1.6.1(@types/node@20.19.43) + packages/retry-policy: {} + packages/shared-types: {} packages/stellar-asset: