mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
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
This commit is contained in:
327
packages/ipuaro/tests/unit/shared/errors/ErrorHandler.test.ts
Normal file
327
packages/ipuaro/tests/unit/shared/errors/ErrorHandler.test.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,22 +1,93 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { IpuaroError } from "../../../../src/shared/errors/IpuaroError.js"
|
||||
import { ERROR_MATRIX, IpuaroError } from "../../../../src/shared/errors/IpuaroError.js"
|
||||
|
||||
describe("IpuaroError", () => {
|
||||
describe("constructor", () => {
|
||||
it("should create error with all fields", () => {
|
||||
const error = new IpuaroError("file", "Not found", true, "Check path")
|
||||
const error = new IpuaroError("file", "Not found", {
|
||||
suggestion: "Check path",
|
||||
context: { filePath: "/test.ts" },
|
||||
})
|
||||
|
||||
expect(error.name).toBe("IpuaroError")
|
||||
expect(error.type).toBe("file")
|
||||
expect(error.message).toBe("Not found")
|
||||
expect(error.recoverable).toBe(true)
|
||||
expect(error.suggestion).toBe("Check path")
|
||||
expect(error.context).toEqual({ filePath: "/test.ts" })
|
||||
})
|
||||
|
||||
it("should default recoverable to true", () => {
|
||||
const error = new IpuaroError("parse", "Parse failed")
|
||||
it("should use matrix defaults for recoverable", () => {
|
||||
const redisError = new IpuaroError("redis", "Connection failed")
|
||||
const parseError = new IpuaroError("parse", "Parse failed")
|
||||
|
||||
expect(error.recoverable).toBe(true)
|
||||
expect(redisError.recoverable).toBe(false)
|
||||
expect(parseError.recoverable).toBe(true)
|
||||
})
|
||||
|
||||
it("should allow overriding recoverable", () => {
|
||||
const error = new IpuaroError("command", "Blacklisted", {
|
||||
recoverable: false,
|
||||
})
|
||||
|
||||
expect(error.recoverable).toBe(false)
|
||||
})
|
||||
|
||||
it("should have options from matrix", () => {
|
||||
const error = new IpuaroError("llm", "Timeout")
|
||||
|
||||
expect(error.options).toEqual(["retry", "skip", "abort"])
|
||||
expect(error.defaultOption).toBe("retry")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMeta", () => {
|
||||
it("should return error metadata", () => {
|
||||
const error = IpuaroError.conflict("File changed")
|
||||
|
||||
const meta = error.getMeta()
|
||||
|
||||
expect(meta.type).toBe("conflict")
|
||||
expect(meta.recoverable).toBe(true)
|
||||
expect(meta.options).toEqual(["skip", "regenerate", "abort"])
|
||||
expect(meta.defaultOption).toBe("skip")
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasOption", () => {
|
||||
it("should return true for available option", () => {
|
||||
const error = IpuaroError.llm("Timeout")
|
||||
|
||||
expect(error.hasOption("retry")).toBe(true)
|
||||
expect(error.hasOption("skip")).toBe(true)
|
||||
expect(error.hasOption("abort")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for unavailable option", () => {
|
||||
const error = IpuaroError.parse("Syntax error")
|
||||
|
||||
expect(error.hasOption("retry")).toBe(false)
|
||||
expect(error.hasOption("regenerate")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("toDisplayString", () => {
|
||||
it("should format error with suggestion", () => {
|
||||
const error = IpuaroError.redis("Connection refused")
|
||||
|
||||
const display = error.toDisplayString()
|
||||
|
||||
expect(display).toContain("[redis]")
|
||||
expect(display).toContain("Connection refused")
|
||||
expect(display).toContain("Suggestion:")
|
||||
})
|
||||
|
||||
it("should format error without suggestion", () => {
|
||||
const error = new IpuaroError("unknown", "Something went wrong")
|
||||
|
||||
const display = error.toDisplayString()
|
||||
|
||||
expect(display).toBe("[unknown] Something went wrong")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +98,13 @@ describe("IpuaroError", () => {
|
||||
expect(error.type).toBe("redis")
|
||||
expect(error.recoverable).toBe(false)
|
||||
expect(error.suggestion).toContain("Redis")
|
||||
expect(error.options).toEqual(["retry", "abort"])
|
||||
})
|
||||
|
||||
it("should create redis error with context", () => {
|
||||
const error = IpuaroError.redis("Connection failed", { host: "localhost" })
|
||||
|
||||
expect(error.context).toEqual({ host: "localhost" })
|
||||
})
|
||||
|
||||
it("should create parse error", () => {
|
||||
@@ -35,12 +113,14 @@ describe("IpuaroError", () => {
|
||||
expect(error.type).toBe("parse")
|
||||
expect(error.message).toContain("test.ts")
|
||||
expect(error.recoverable).toBe(true)
|
||||
expect(error.context).toEqual({ filePath: "test.ts" })
|
||||
})
|
||||
|
||||
it("should create parse error without file", () => {
|
||||
const error = IpuaroError.parse("Syntax error")
|
||||
|
||||
expect(error.message).toBe("Syntax error")
|
||||
expect(error.context).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should create llm error", () => {
|
||||
@@ -51,36 +131,113 @@ describe("IpuaroError", () => {
|
||||
expect(error.suggestion).toContain("Ollama")
|
||||
})
|
||||
|
||||
it("should create llmTimeout error", () => {
|
||||
const error = IpuaroError.llmTimeout("Request timed out")
|
||||
|
||||
expect(error.type).toBe("timeout")
|
||||
expect(error.suggestion).toContain("timed out")
|
||||
})
|
||||
|
||||
it("should create file error", () => {
|
||||
const error = IpuaroError.file("Not found")
|
||||
const error = IpuaroError.file("Not found", "/path/to/file.ts")
|
||||
|
||||
expect(error.type).toBe("file")
|
||||
expect(error.context).toEqual({ filePath: "/path/to/file.ts" })
|
||||
})
|
||||
|
||||
it("should create fileNotFound error", () => {
|
||||
const error = IpuaroError.fileNotFound("/path/to/file.ts")
|
||||
|
||||
expect(error.type).toBe("file")
|
||||
expect(error.message).toContain("/path/to/file.ts")
|
||||
expect(error.context).toEqual({ filePath: "/path/to/file.ts" })
|
||||
})
|
||||
|
||||
it("should create command error", () => {
|
||||
const error = IpuaroError.command("Blacklisted")
|
||||
const error = IpuaroError.command("Not in whitelist", "rm -rf /")
|
||||
|
||||
expect(error.type).toBe("command")
|
||||
expect(error.context).toEqual({ command: "rm -rf /" })
|
||||
})
|
||||
|
||||
it("should create commandBlacklisted error", () => {
|
||||
const error = IpuaroError.commandBlacklisted("rm -rf /")
|
||||
|
||||
expect(error.type).toBe("command")
|
||||
expect(error.recoverable).toBe(false)
|
||||
expect(error.message).toContain("blacklisted")
|
||||
})
|
||||
|
||||
it("should create conflict error", () => {
|
||||
const error = IpuaroError.conflict("File changed")
|
||||
const error = IpuaroError.conflict("File changed", "test.ts")
|
||||
|
||||
expect(error.type).toBe("conflict")
|
||||
expect(error.suggestion).toContain("Regenerate")
|
||||
expect(error.context).toEqual({ filePath: "test.ts" })
|
||||
})
|
||||
|
||||
it("should create validation error", () => {
|
||||
const error = IpuaroError.validation("Invalid param")
|
||||
const error = IpuaroError.validation("Invalid param", "name")
|
||||
|
||||
expect(error.type).toBe("validation")
|
||||
expect(error.context).toEqual({ field: "name" })
|
||||
})
|
||||
|
||||
it("should create timeout error", () => {
|
||||
const error = IpuaroError.timeout("Request timeout")
|
||||
const error = IpuaroError.timeout("Request timeout", 5000)
|
||||
|
||||
expect(error.type).toBe("timeout")
|
||||
expect(error.suggestion).toContain("timeout")
|
||||
expect(error.context).toEqual({ timeoutMs: 5000 })
|
||||
})
|
||||
|
||||
it("should create unknown error", () => {
|
||||
const original = new Error("Something broke")
|
||||
const error = IpuaroError.unknown("Unknown error", original)
|
||||
|
||||
expect(error.type).toBe("unknown")
|
||||
expect(error.recoverable).toBe(false)
|
||||
expect(error.context).toEqual({ originalError: "Something broke" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("ERROR_MATRIX", () => {
|
||||
it("should have all error types defined", () => {
|
||||
const types = [
|
||||
"redis",
|
||||
"parse",
|
||||
"llm",
|
||||
"file",
|
||||
"command",
|
||||
"conflict",
|
||||
"validation",
|
||||
"timeout",
|
||||
"unknown",
|
||||
]
|
||||
|
||||
for (const type of types) {
|
||||
expect(ERROR_MATRIX[type as keyof typeof ERROR_MATRIX]).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it("should have correct non-recoverable errors", () => {
|
||||
expect(ERROR_MATRIX.redis.recoverable).toBe(false)
|
||||
expect(ERROR_MATRIX.unknown.recoverable).toBe(false)
|
||||
})
|
||||
|
||||
it("should have correct recoverable errors", () => {
|
||||
expect(ERROR_MATRIX.parse.recoverable).toBe(true)
|
||||
expect(ERROR_MATRIX.llm.recoverable).toBe(true)
|
||||
expect(ERROR_MATRIX.file.recoverable).toBe(true)
|
||||
expect(ERROR_MATRIX.command.recoverable).toBe(true)
|
||||
expect(ERROR_MATRIX.conflict.recoverable).toBe(true)
|
||||
expect(ERROR_MATRIX.timeout.recoverable).toBe(true)
|
||||
})
|
||||
|
||||
it("should have abort option for all error types", () => {
|
||||
for (const config of Object.values(ERROR_MATRIX)) {
|
||||
expect(config.options).toContain("abort")
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user