Files
puaros/packages/ipuaro/tests/unit/shared/errors/ErrorHandler.test.ts
imfozilbek 8f995fc596 feat(ipuaro): add error handling matrix and ErrorHandler service
Implemented comprehensive error handling system according to v0.16.0 roadmap:

- ERROR_MATRIX with 9 error types (redis, parse, llm, file, command, conflict, validation, timeout, unknown)
- Enhanced IpuaroError with options, defaultOption, context properties
- New methods: getMeta(), hasOption(), toDisplayString()
- ErrorHandler service with handle(), wrap(), withRetry() methods
- Utility functions: getErrorOptions(), isRecoverableError(), toIpuaroError()
- 59 new tests (27 for IpuaroError, 32 for ErrorHandler)
- Coverage maintained at 97.59%

Breaking changes:
- IpuaroError constructor signature changed to (type, message, options?)
- ErrorChoice deprecated in favor of ErrorOption
2025-12-01 15:50:30 +05:00

328 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest"
import {
createErrorHandler,
ErrorHandler,
getDefaultErrorOption,
getErrorOptions,
isRecoverableError,
toIpuaroError,
} from "../../../../src/shared/errors/ErrorHandler.js"
import { IpuaroError } from "../../../../src/shared/errors/IpuaroError.js"
describe("ErrorHandler", () => {
let handler: ErrorHandler
beforeEach(() => {
handler = new ErrorHandler()
})
describe("handle", () => {
it("should abort non-recoverable errors", async () => {
const error = IpuaroError.redis("Connection failed")
const result = await handler.handle(error)
expect(result.action).toBe("abort")
expect(result.shouldContinue).toBe(false)
})
it("should use default option for recoverable errors without callback", async () => {
const error = IpuaroError.llm("Timeout")
const result = await handler.handle(error)
expect(result.action).toBe("retry")
expect(result.shouldContinue).toBe(true)
})
it("should call onError callback when provided", async () => {
const onError = vi.fn().mockResolvedValue("skip")
const handler = new ErrorHandler({ onError })
const error = IpuaroError.llm("Timeout")
const result = await handler.handle(error)
expect(onError).toHaveBeenCalledWith(error, error.options, error.defaultOption)
expect(result.action).toBe("skip")
})
it("should auto-skip parse errors when enabled", async () => {
const handler = new ErrorHandler({ autoSkipParseErrors: true })
const error = IpuaroError.parse("Syntax error")
const result = await handler.handle(error)
expect(result.action).toBe("skip")
expect(result.shouldContinue).toBe(true)
})
it("should auto-retry LLM errors when enabled", async () => {
const handler = new ErrorHandler({ autoRetryLLMErrors: true })
const error = IpuaroError.llm("Timeout")
const result = await handler.handle(error, "test-key")
expect(result.action).toBe("retry")
expect(result.shouldContinue).toBe(true)
expect(result.retryCount).toBe(1)
})
it("should track retry count", async () => {
const handler = new ErrorHandler({ autoRetryLLMErrors: true })
const error = IpuaroError.llm("Timeout")
await handler.handle(error, "test-key")
await handler.handle(error, "test-key")
const result = await handler.handle(error, "test-key")
expect(result.retryCount).toBe(3)
})
it("should abort after max retries", async () => {
const handler = new ErrorHandler({ autoRetryLLMErrors: true, maxRetries: 2 })
const error = IpuaroError.llm("Timeout")
await handler.handle(error, "test-key")
await handler.handle(error, "test-key")
const result = await handler.handle(error, "test-key")
expect(result.action).toBe("abort")
expect(result.shouldContinue).toBe(false)
})
})
describe("handleSync", () => {
it("should abort non-recoverable errors", () => {
const error = IpuaroError.redis("Connection failed")
const result = handler.handleSync(error)
expect(result.action).toBe("abort")
expect(result.shouldContinue).toBe(false)
})
it("should use default option for recoverable errors", () => {
const error = IpuaroError.file("Not found")
const result = handler.handleSync(error)
expect(result.action).toBe("skip")
expect(result.shouldContinue).toBe(true)
})
})
describe("resetRetries", () => {
it("should reset specific context", async () => {
const handler = new ErrorHandler({ autoRetryLLMErrors: true })
const error = IpuaroError.llm("Timeout")
await handler.handle(error, "key1")
await handler.handle(error, "key1")
handler.resetRetries("key1")
expect(handler.getRetryCount("key1")).toBe(0)
})
it("should reset all contexts when no key provided", async () => {
const handler = new ErrorHandler({ autoRetryLLMErrors: true })
const error = IpuaroError.llm("Timeout")
await handler.handle(error, "key1")
await handler.handle(error, "key2")
handler.resetRetries()
expect(handler.getRetryCount("key1")).toBe(0)
expect(handler.getRetryCount("key2")).toBe(0)
})
})
describe("getRetryCount", () => {
it("should return 0 for unknown context", () => {
expect(handler.getRetryCount("unknown")).toBe(0)
})
})
describe("isMaxRetriesExceeded", () => {
it("should return false when retries not exceeded", () => {
expect(handler.isMaxRetriesExceeded("test")).toBe(false)
})
it("should return true when retries exceeded", async () => {
const handler = new ErrorHandler({ autoRetryLLMErrors: true, maxRetries: 1 })
const error = IpuaroError.llm("Timeout")
await handler.handle(error, "test")
expect(handler.isMaxRetriesExceeded("test")).toBe(true)
})
})
describe("wrap", () => {
it("should return success result on success", async () => {
const fn = vi.fn().mockResolvedValue("result")
const result = await handler.wrap(fn, "llm")
expect(result.success).toBe(true)
if (result.success) {
expect(result.data).toBe("result")
}
})
it("should return error result on failure", async () => {
const fn = vi.fn().mockRejectedValue(new Error("Failed"))
const result = await handler.wrap(fn, "llm")
expect(result.success).toBe(false)
if (!result.success) {
expect(result.result.action).toBe("retry")
}
})
it("should handle IpuaroError directly", async () => {
const fn = vi.fn().mockRejectedValue(IpuaroError.file("Not found"))
const result = await handler.wrap(fn, "llm")
expect(result.success).toBe(false)
if (!result.success) {
expect(result.result.action).toBe("skip")
}
})
it("should reset retries on success", async () => {
const handler = new ErrorHandler({ autoRetryLLMErrors: true })
const error = IpuaroError.llm("Timeout")
await handler.handle(error, "test-key")
await handler.wrap(() => Promise.resolve("ok"), "llm", "test-key")
expect(handler.getRetryCount("test-key")).toBe(0)
})
})
describe("withRetry", () => {
it("should return result on success", async () => {
const fn = vi.fn().mockResolvedValue("result")
const result = await handler.withRetry(fn, "llm", "test")
expect(result).toBe("result")
})
it("should retry on failure", async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error("Fail 1"))
.mockResolvedValueOnce("success")
const handler = new ErrorHandler({
onError: vi.fn().mockResolvedValue("retry"),
})
const result = await handler.withRetry(fn, "llm", "test")
expect(result).toBe("success")
expect(fn).toHaveBeenCalledTimes(2)
})
it("should throw after max retries", async () => {
const fn = vi.fn().mockRejectedValue(new Error("Always fails"))
const handler = new ErrorHandler({
maxRetries: 2,
onError: vi.fn().mockResolvedValue("retry"),
})
await expect(handler.withRetry(fn, "llm", "test")).rejects.toThrow("Max retries")
})
it("should throw immediately when skip is chosen", async () => {
const fn = vi.fn().mockRejectedValue(new Error("Fail"))
const handler = new ErrorHandler({
onError: vi.fn().mockResolvedValue("skip"),
})
await expect(handler.withRetry(fn, "llm", "test")).rejects.toThrow("Fail")
})
})
})
describe("utility functions", () => {
describe("getErrorOptions", () => {
it("should return options for error type", () => {
const options = getErrorOptions("llm")
expect(options).toEqual(["retry", "skip", "abort"])
})
})
describe("getDefaultErrorOption", () => {
it("should return default option for error type", () => {
expect(getDefaultErrorOption("llm")).toBe("retry")
expect(getDefaultErrorOption("parse")).toBe("skip")
expect(getDefaultErrorOption("redis")).toBe("abort")
})
})
describe("isRecoverableError", () => {
it("should return true for recoverable errors", () => {
expect(isRecoverableError("llm")).toBe(true)
expect(isRecoverableError("parse")).toBe(true)
expect(isRecoverableError("file")).toBe(true)
})
it("should return false for non-recoverable errors", () => {
expect(isRecoverableError("redis")).toBe(false)
expect(isRecoverableError("unknown")).toBe(false)
})
})
describe("toIpuaroError", () => {
it("should return IpuaroError as-is", () => {
const error = IpuaroError.llm("Timeout")
const result = toIpuaroError(error)
expect(result).toBe(error)
})
it("should convert Error to IpuaroError", () => {
const error = new Error("Something went wrong")
const result = toIpuaroError(error, "llm")
expect(result).toBeInstanceOf(IpuaroError)
expect(result.type).toBe("llm")
expect(result.message).toBe("Something went wrong")
})
it("should convert string to IpuaroError", () => {
const result = toIpuaroError("Error message", "file")
expect(result).toBeInstanceOf(IpuaroError)
expect(result.type).toBe("file")
expect(result.message).toBe("Error message")
})
it("should use unknown type by default", () => {
const result = toIpuaroError("Error")
expect(result.type).toBe("unknown")
})
})
describe("createErrorHandler", () => {
it("should create handler with default options", () => {
const handler = createErrorHandler()
expect(handler).toBeInstanceOf(ErrorHandler)
})
it("should create handler with custom options", () => {
const handler = createErrorHandler({ maxRetries: 5 })
expect(handler).toBeInstanceOf(ErrorHandler)
})
})
})