feat(KB-159): complete Step 4 — update frontend for streaming display
This commit is contained in:
187
packages/engine/src/transient-error-detector.test.ts
Normal file
187
packages/engine/src/transient-error-detector.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isTransientError,
|
||||
classifyError,
|
||||
TRANSIENT_ERROR_PATTERNS,
|
||||
} from "./transient-error-detector.js";
|
||||
import { isUsageLimitError } from "./usage-limit-detector.js";
|
||||
|
||||
describe("Transient Error Detector", () => {
|
||||
describe("isTransientError", () => {
|
||||
// Core error messages from the task description
|
||||
it("matches the full upstream connect error message", () => {
|
||||
const message =
|
||||
"upstream connect error or disconnect/reset before headers. retried and the latest reset reason: remote connection failure, transport failure reason: delayed connect error: Connection refused";
|
||||
expect(isTransientError(message)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 'upstream connect error'", () => {
|
||||
expect(isTransientError("upstream connect error")).toBe(true);
|
||||
expect(isTransientError("Upstream Connect Error")).toBe(true);
|
||||
expect(isTransientError("UPSTREAM CONNECT ERROR")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 'disconnect/reset before headers'", () => {
|
||||
expect(isTransientError("disconnect/reset before headers")).toBe(true);
|
||||
expect(isTransientError("Disconnect/Reset Before Headers")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 'retried and the latest reset reason'", () => {
|
||||
expect(isTransientError("retried and the latest reset reason: timeout")).toBe(true);
|
||||
expect(isTransientError("Retried And The Latest Reset Reason")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 'remote connection failure'", () => {
|
||||
expect(isTransientError("remote connection failure")).toBe(true);
|
||||
expect(isTransientError("Remote Connection Failure")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 'transport failure reason'", () => {
|
||||
expect(isTransientError("transport failure reason: connection reset")).toBe(true);
|
||||
expect(isTransientError("Transport Failure Reason")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 'delayed connect error'", () => {
|
||||
expect(isTransientError("delayed connect error: Connection refused")).toBe(true);
|
||||
expect(isTransientError("Delayed Connect Error")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 'Connection refused'", () => {
|
||||
expect(isTransientError("Connection refused")).toBe(true);
|
||||
expect(isTransientError("connection refused")).toBe(true);
|
||||
expect(isTransientError("CONNECTION REFUSED")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 'connection reset'", () => {
|
||||
expect(isTransientError("connection reset by peer")).toBe(true);
|
||||
expect(isTransientError("Connection Reset")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 'ECONNREFUSED'", () => {
|
||||
expect(isTransientError("ECONNREFUSED")).toBe(true);
|
||||
expect(isTransientError("Error: ECONNREFUSED")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 'ETIMEDOUT'", () => {
|
||||
expect(isTransientError("ETIMEDOUT")).toBe(true);
|
||||
expect(isTransientError("Error: ETIMEDOUT")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 'socket hang up'", () => {
|
||||
expect(isTransientError("socket hang up")).toBe(true);
|
||||
expect(isTransientError("Socket Hang Up")).toBe(true);
|
||||
expect(isTransientError("Error: socket hang up")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches connection timeout patterns", () => {
|
||||
expect(isTransientError("connection timeout")).toBe(true);
|
||||
expect(isTransientError("timeout connection to server")).toBe(true);
|
||||
});
|
||||
|
||||
// Edge cases
|
||||
it("returns false for empty string", () => {
|
||||
expect(isTransientError("")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null", () => {
|
||||
expect(isTransientError(null as unknown as string)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isTransientError(undefined as unknown as string)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for non-string values", () => {
|
||||
expect(isTransientError(123 as unknown as string)).toBe(false);
|
||||
expect(isTransientError({} as unknown as string)).toBe(false);
|
||||
expect(isTransientError([] as unknown as string)).toBe(false);
|
||||
});
|
||||
|
||||
// Should NOT match non-transient errors
|
||||
it("returns false for code errors", () => {
|
||||
expect(isTransientError("SyntaxError: Unexpected token")).toBe(false);
|
||||
expect(isTransientError("TypeError: Cannot read property")).toBe(false);
|
||||
expect(isTransientError("ReferenceError: foo is not defined")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for test failures", () => {
|
||||
expect(isTransientError("Assertion failed: expected 1 to be 2")).toBe(false);
|
||||
expect(isTransientError("Test timeout of 5000ms exceeded")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for usage limit errors", () => {
|
||||
expect(isTransientError("rate limit exceeded")).toBe(false);
|
||||
expect(isTransientError("429 Too Many Requests")).toBe(false);
|
||||
expect(isTransientError("API quota exceeded")).toBe(false);
|
||||
});
|
||||
|
||||
// Partial matches should not trigger false positives
|
||||
it("handles partial matches correctly", () => {
|
||||
// Should not match just "error" or "timeout" without connection context
|
||||
expect(isTransientError("An error occurred")).toBe(false);
|
||||
// "timeout" alone is not in the patterns (only connection timeouts)
|
||||
expect(isTransientError("timeout")).toBe(false);
|
||||
expect(isTransientError("Request timeout")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyError", () => {
|
||||
it("classifies usage limit errors as 'usage-limit'", () => {
|
||||
expect(classifyError("rate limit exceeded")).toBe("usage-limit");
|
||||
expect(classifyError("429 Too Many Requests")).toBe("usage-limit");
|
||||
expect(classifyError("API overloaded")).toBe("usage-limit");
|
||||
expect(classifyError("quota exceeded")).toBe("usage-limit");
|
||||
expect(classifyError("billing issue")).toBe("usage-limit");
|
||||
});
|
||||
|
||||
it("classifies transient errors as 'transient'", () => {
|
||||
expect(classifyError("upstream connect error")).toBe("transient");
|
||||
expect(classifyError("ECONNREFUSED")).toBe("transient");
|
||||
expect(classifyError("socket hang up")).toBe("transient");
|
||||
expect(classifyError("Connection refused")).toBe("transient");
|
||||
});
|
||||
|
||||
it("classifies all other errors as 'permanent'", () => {
|
||||
expect(classifyError("SyntaxError: Unexpected token")).toBe("permanent");
|
||||
expect(classifyError("Test failed")).toBe("permanent");
|
||||
expect(classifyError("Build error")).toBe("permanent");
|
||||
});
|
||||
|
||||
// Priority: usage limit > transient > permanent
|
||||
it("prioritizes usage limits over transient errors", () => {
|
||||
// Usage limit patterns should take precedence
|
||||
const usageLimitMsg = "rate limit exceeded while connecting";
|
||||
expect(isUsageLimitError(usageLimitMsg)).toBe(true);
|
||||
expect(classifyError(usageLimitMsg)).toBe("usage-limit");
|
||||
});
|
||||
|
||||
it("handles empty/invalid input as 'permanent'", () => {
|
||||
expect(classifyError("")).toBe("permanent");
|
||||
expect(classifyError(null as unknown as string)).toBe("permanent");
|
||||
expect(classifyError(undefined as unknown as string)).toBe("permanent");
|
||||
});
|
||||
|
||||
it("classifies the full complex error message correctly", () => {
|
||||
const message =
|
||||
"upstream connect error or disconnect/reset before headers. retried and the latest reset reason: remote connection failure, transport failure reason: delayed connect error: Connection refused";
|
||||
expect(classifyError(message)).toBe("transient");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TRANSIENT_ERROR_PATTERNS", () => {
|
||||
it("exports the patterns array", () => {
|
||||
expect(Array.isArray(TRANSIENT_ERROR_PATTERNS)).toBe(true);
|
||||
expect(TRANSIENT_ERROR_PATTERNS.length).toBeGreaterThan(0);
|
||||
// All patterns should be RegExp
|
||||
TRANSIENT_ERROR_PATTERNS.forEach((pattern) => {
|
||||
expect(pattern).toBeInstanceOf(RegExp);
|
||||
});
|
||||
});
|
||||
|
||||
it("all patterns have case-insensitive flag", () => {
|
||||
TRANSIENT_ERROR_PATTERNS.forEach((pattern) => {
|
||||
expect(pattern.flags).toContain("i");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
108
packages/engine/src/transient-error-detector.ts
Normal file
108
packages/engine/src/transient-error-detector.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Transient Error Detector — classifies network/infrastructure errors as transient
|
||||
* (temporary and retryable) versus permanent failures.
|
||||
*
|
||||
* Transient errors indicate temporary conditions like network blips, proxy hiccups,
|
||||
* connection resets, or temporary service unavailability. These errors typically
|
||||
* resolve on their own after a short delay and should NOT mark tasks as failed.
|
||||
*
|
||||
* When a transient error is detected, the task should be moved back to "todo"
|
||||
* for later retry rather than being marked as "failed". This prevents tasks from
|
||||
* being incorrectly marked as failed due to temporary infrastructure issues.
|
||||
*
|
||||
* Contrast with:
|
||||
* - Usage limit errors: Systemic conditions (rate limits, quota) → trigger global pause
|
||||
* - Permanent errors: Code issues, test failures, logic errors → mark task as failed
|
||||
*/
|
||||
|
||||
import { isUsageLimitError } from "./usage-limit-detector.js";
|
||||
|
||||
/**
|
||||
* Patterns that indicate transient network/infrastructure errors.
|
||||
* These are checked case-insensitively against error messages.
|
||||
*
|
||||
* These patterns cover:
|
||||
* - Proxy/gateway connection errors (upstream connect, disconnect/reset)
|
||||
* - Connection refusal/reset (ECONNREFUSED, connection reset)
|
||||
* - Timeouts (ETIMEDOUT, timeout in connection context)
|
||||
* - Socket errors (socket hang up)
|
||||
* - Transport layer failures
|
||||
*/
|
||||
export const TRANSIENT_ERROR_PATTERNS: RegExp[] = [
|
||||
// Proxy/gateway errors - indicate temporary routing issues
|
||||
/upstream connect error/i,
|
||||
/disconnect\/reset before headers/i,
|
||||
/retried and the latest reset reason/i,
|
||||
/remote connection failure/i,
|
||||
/transport failure reason/i,
|
||||
/delayed connect error/i,
|
||||
|
||||
// Connection establishment failures - usually temporary
|
||||
/Connection refused/i,
|
||||
/connection reset/i,
|
||||
/ECONNREFUSED/i,
|
||||
/ETIMEDOUT/i,
|
||||
/socket hang up/i,
|
||||
|
||||
// Timeout patterns (only when related to connections, not general timeouts)
|
||||
/timeout.*connection/i,
|
||||
/connection.*timeout/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if an error message indicates a transient network/infrastructure error.
|
||||
*
|
||||
* Transient errors are temporary conditions that typically resolve after a delay:
|
||||
* - Network blips and temporary routing issues
|
||||
* - Proxy/gateway hiccups (upstream connect errors)
|
||||
* - Connection resets during establishment
|
||||
* - Temporary service unavailability (connection refused)
|
||||
* - Socket timeouts during connection
|
||||
*
|
||||
* Returns `true` for transient errors — these should trigger a retry by moving
|
||||
* the task back to "todo" rather than marking as "failed".
|
||||
*
|
||||
* Returns `false` for permanent failures (code errors, test failures) or
|
||||
* usage limit errors (rate limits that need global pause).
|
||||
*
|
||||
* @param errorMessage - The error message to classify
|
||||
* @returns true if the error appears transient and retryable
|
||||
*/
|
||||
export function isTransientError(errorMessage: string): boolean {
|
||||
if (!errorMessage || typeof errorMessage !== "string") {
|
||||
return false;
|
||||
}
|
||||
return TRANSIENT_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage));
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive error classification that distinguishes between:
|
||||
* - 'usage-limit': Rate limits, quota exceeded, billing issues → triggers global pause
|
||||
* - 'transient': Network blips, connection errors → move task to "todo" for retry
|
||||
* - 'permanent': Code errors, test failures, logic errors → mark task as failed
|
||||
*
|
||||
* This function delegates to existing usage limit detection first (to preserve
|
||||
* existing behavior), then checks for transient patterns, defaulting to
|
||||
* 'permanent' for all other errors.
|
||||
*
|
||||
* @param errorMessage - The error message to classify
|
||||
* @returns The error classification category
|
||||
*/
|
||||
export function classifyError(errorMessage: string): "transient" | "usage-limit" | "permanent" {
|
||||
if (!errorMessage || typeof errorMessage !== "string") {
|
||||
return "permanent";
|
||||
}
|
||||
|
||||
// Check usage limits first (highest priority - triggers global pause)
|
||||
if (isUsageLimitError(errorMessage)) {
|
||||
return "usage-limit";
|
||||
}
|
||||
|
||||
// Check transient patterns next (move to todo for retry)
|
||||
if (isTransientError(errorMessage)) {
|
||||
return "transient";
|
||||
}
|
||||
|
||||
// Default to permanent (mark as failed)
|
||||
return "permanent";
|
||||
}
|
||||
Reference in New Issue
Block a user