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:
imfozilbek
2025-12-01 15:50:30 +05:00
parent f947c6d157
commit 8f995fc596
11 changed files with 1089 additions and 70 deletions

View 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)
})
})
})

View File

@@ -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")
}
})
})
})