Files
shannon/apps/worker/src/services/error-handling.ts
T

383 lines
16 KiB
TypeScript

// Copyright (C) 2026 Keygraph, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License version 3
// as published by the Free Software Foundation.
import { type AssistantMessage, isContextOverflow, isRetryableAssistantError } from '@earendil-works/pi-ai';
import {
ErrorCode,
type PentestErrorContext,
type PentestErrorType,
type PromptErrorResult,
type ProviderFailure,
type ProviderFailureCategory,
} from '../types/errors.js';
// The provider boundary answers two independent questions and never lets one decide the other:
//
// - Retryability: no Shannon-owned parser decides this. A typed PentestError keeps its own
// verdict, context overflow is terminal, a genuinely-thrown SDK error with structured
// status/headers mirrors pi's request-layer policy, and every other (flattened) failure
// defers to pi's own isRetryableAssistantError helper.
// - Category: an observational label emitted only from reliable positive evidence — a preserved
// category, a typed PentestError, the context-overflow check, or structured status. It is
// never guessed from free text and never derived from the retry boolean.
//
// The matched provider text is always discarded and replaced with the fixed
// PROVIDER_FAILURE_MESSAGES entry, so raw provider responses never reach durable state or output.
// Node system error codes for transient transport faults. These are structured fields on a
// genuinely-thrown error, not provider prose, so reading them is not the text-parsing the boundary
// avoids. pi's flattened-message helper recognizes the prose forms ("connection refused", "fetch
// failed") but not these raw codes, so a thrown ECONNRESET would otherwise fail closed as terminal.
const NODE_TRANSPORT_ERROR_CODES: ReadonlySet<string> = new Set([
'ECONNRESET',
'ECONNREFUSED',
'ETIMEDOUT',
'EPIPE',
'EAI_AGAIN',
'ENOTFOUND',
]);
const PROVIDER_FAILURE_MESSAGES: Readonly<Record<ProviderFailureCategory, string>> = {
rate_limit: 'The provider rate-limited the model request.',
overloaded: 'The provider was temporarily overloaded.',
transport: 'The provider request failed because of a transient transport error.',
context_limit: 'The model request exceeded the provider context limit.',
quota: 'The provider quota is exhausted.',
authentication: 'Provider authentication failed. Verify the configured credential.',
configuration: 'Provider configuration is invalid. Verify the selected provider, model, and endpoint.',
unknown: 'The provider rejected the model request with a non-retryable error.',
} as const;
export class PentestError extends Error {
override name = 'PentestError' as const;
type: PentestErrorType;
retryable: boolean;
context: PentestErrorContext;
timestamp: string;
/** Optional specific error code for reliable classification */
code?: ErrorCode;
/** Sanitized provider category, preserved across boundaries so it is not reclassified and degraded. */
providerCategory?: ProviderFailureCategory;
constructor(
message: string,
type: PentestErrorType,
retryable: boolean = false,
context: PentestErrorContext = {},
code?: ErrorCode,
) {
super(message);
this.type = type;
this.retryable = retryable;
this.context = context;
this.timestamp = new Date().toISOString();
if (code !== undefined) {
this.code = code;
}
}
}
export function handlePromptError(promptName: string, error: Error): PromptErrorResult {
return {
success: false,
error: new PentestError(`Failed to load prompt '${promptName}': ${error.message}`, 'prompt', false, {
promptName,
originalError: error.message,
}),
};
}
/**
* Whether a failed agent attempt is worth retrying.
*
* Uniform across every caller: the same retry rule decides preflight, ordinary agents,
* task formation, SAST enrichment, and Capella. No provider text is parsed here.
*/
export function isRetryableFailure(error: unknown): boolean {
return isProviderRetryable(error);
}
function providerFailure(
category: ProviderFailureCategory,
retryable: boolean,
type: ProviderFailure['type'],
): ProviderFailure {
return { type, category, retryable, message: PROVIDER_FAILURE_MESSAGES[category] };
}
function providerFailureText(error: unknown): string {
if (
typeof error === 'object' &&
error !== null &&
'errorMessage' in error &&
typeof error.errorMessage === 'string'
) {
return error.errorMessage;
}
return error instanceof Error ? error.message : String(error);
}
function objectRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function parseProviderStatus(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599) return value;
if (typeof value === 'string' && /^[1-5]\d{2}$/u.test(value.trim())) return Number(value.trim());
return undefined;
}
/** Read only conventional bounded status fields; never stringify provider objects for classification. */
function structuredProviderStatus(error: unknown): number | undefined {
const record = objectRecord(error);
if (!record) return undefined;
const response = objectRecord(record.response);
const metadata = objectRecord(record.$metadata);
const candidates = [
record.status,
record.statusCode,
record.status_code,
response?.status,
response?.statusCode,
metadata?.httpStatusCode,
];
for (const candidate of candidates) {
const status = parseProviderStatus(candidate);
if (status !== undefined) return status;
}
return undefined;
}
/**
* An explicit x-should-retry verdict from a genuinely-thrown SDK error's headers, matching the
* header pi honors in its own request-layer retry loop. Only present on real thrown errors.
*/
function structuredRetryHeader(error: unknown): boolean | undefined {
const record = objectRecord(error);
if (!record) return undefined;
const headers = record.headers;
let raw: unknown;
if (headers != null && typeof (headers as { get?: unknown }).get === 'function') {
raw = (headers as { get: (name: string) => unknown }).get('x-should-retry');
} else {
raw = objectRecord(headers)?.['x-should-retry'];
}
if (raw === undefined || raw === null) return undefined;
const value = String(raw).trim().toLowerCase();
if (value === 'true') return true;
if (value === 'false') return false;
return undefined;
}
/**
* Shape a failure into the AssistantMessage pi's helpers expect. A real assistant message is
* passed through unchanged so pi can still detect silent overflow from its usage; anything else
* is wrapped as an errored turn carrying only its already-derived text.
*/
function asAssistantMessage(error: unknown, text: string): AssistantMessage {
const record = objectRecord(error);
if (record && 'stopReason' in record && typeof record.errorMessage === 'string') {
return error as AssistantMessage;
}
return { role: 'assistant', stopReason: 'error', errorMessage: text } as AssistantMessage;
}
function isProviderOverflow(error: unknown, text: string, contextWindow?: number): boolean {
return isContextOverflow(asAssistantMessage(error, text), contextWindow);
}
/** Structured HTTP status → observational category. Mirrors pi's request-layer status semantics. */
function categoryForProviderStatus(status: number): ProviderFailureCategory | undefined {
if (status === 401 || status === 403) return 'authentication';
if (status === 413) return 'context_limit';
if (status === 429) return 'rate_limit';
if (status === 408 || status === 409) return 'transport';
if (status >= 500) return 'overloaded';
return undefined;
}
/** Whether a structured status should retry, mirroring pi's request-layer policy. */
function isRetryableProviderStatus(status: number): boolean {
return status === 408 || status === 409 || status === 429 || status >= 500;
}
/** A transient transport fault identified by a Node system error code on the error or its cause. */
function structuredTransportCode(error: unknown): boolean {
const record = objectRecord(error);
if (!record) return false;
if (typeof record.code === 'string' && NODE_TRANSPORT_ERROR_CODES.has(record.code)) return true;
const causeCode = objectRecord(record.cause)?.code;
return typeof causeCode === 'string' && NODE_TRANSPORT_ERROR_CODES.has(causeCode);
}
/**
* Retryability of a failed attempt, computed independently of the category. Typed errors keep
* their verdict; context overflow is terminal; structured status/headers mirror pi's request
* layer; every flattened failure defers to pi's own helper. Nothing else parses provider text.
*/
export function isProviderRetryable(error: unknown, contextWindow?: number): boolean {
if (error instanceof PentestError) {
if (error.code !== undefined) return classifyByErrorCode(error.code, error.retryable).retryable;
return error.retryable;
}
const text = providerFailureText(error);
if (isProviderOverflow(error, text, contextWindow)) return false;
const header = structuredRetryHeader(error);
if (header !== undefined) return header;
const status = structuredProviderStatus(error);
if (status !== undefined) return isRetryableProviderStatus(status);
if (structuredTransportCode(error)) return true;
return isRetryableAssistantError(asAssistantMessage(error, text));
}
/** Error type of a provider failure. Only a typed PentestError may be auth/config; raw messages never are. */
function providerType(error: unknown): ProviderFailure['type'] {
if (error instanceof PentestError && error.code !== undefined) {
const classified = classifyByErrorCode(error.code, error.retryable);
if (classified.type === 'AuthenticationError') return 'AuthenticationError';
if (classified.type === 'ConfigurationError') return 'ConfigurationError';
}
return 'AgentExecutionError';
}
/**
* Observational category, computed independently of retryability. Positive evidence only: a
* preserved category, a typed PentestError, the context-overflow check, or structured status.
* A flattened failure with no such evidence is honestly `unknown`, never a guess.
*/
export function providerCategory(error: unknown, contextWindow?: number): ProviderFailureCategory {
if (error instanceof PentestError) {
if (error.providerCategory !== undefined) return error.providerCategory;
if (error.code !== undefined) {
const classified = classifyByErrorCode(error.code, error.retryable);
if (classified.type === 'AuthenticationError') return 'authentication';
if (classified.type === 'ConfigurationError') return 'configuration';
}
return 'unknown';
}
const text = providerFailureText(error);
if (isProviderOverflow(error, text, contextWindow)) return 'context_limit';
const status = structuredProviderStatus(error);
if (status !== undefined) {
const category = categoryForProviderStatus(status);
if (category !== undefined) return category;
}
if (structuredTransportCode(error)) return 'transport';
return 'unknown';
}
/** Classify a provider failure without carrying provider response text across the boundary. */
export function classifyProviderFailure(error: unknown, contextWindow?: number): ProviderFailure {
return providerFailure(
providerCategory(error, contextWindow),
isProviderRetryable(error, contextWindow),
providerType(error),
);
}
/**
* Bounded machine code for a classified provider failure. It is derived only from the
* classification, so nothing the provider wrote can reach a stored or logged message.
*/
export function providerFailureCode(failure: ProviderFailure): string {
return failure.category;
}
/** The one sentence a rejected model request produces, carrying only its bounded code. */
export function providerFailureSentence(failure: ProviderFailure): string {
return `The model provider rejected the request (${providerFailureCode(failure)}).`;
}
/**
* Classifies errors by ErrorCode for reliable, code-based classification.
* Used when error is a PentestError with a specific ErrorCode.
*/
function classifyByErrorCode(code: ErrorCode, retryableFromError: boolean): { type: string; retryable: boolean } {
switch (code) {
// Config errors - non-retryable (need manual fix)
case ErrorCode.CONFIG_NOT_FOUND:
case ErrorCode.CONFIG_VALIDATION_FAILED:
case ErrorCode.CONFIG_PARSE_ERROR:
return { type: 'ConfigurationError', retryable: false };
// Prompt errors - non-retryable (need manual fix)
case ErrorCode.PROMPT_LOAD_FAILED:
return { type: 'ConfigurationError', retryable: false };
case ErrorCode.GIT_CHECKPOINT_FAILED:
return { type: 'GitError', retryable: retryableFromError };
// Rollback errors leave the workspace state untrusted.
case ErrorCode.GIT_ROLLBACK_FAILED:
return { type: 'GitError', retryable: false };
// Validation errors - retryable (agent may succeed on retry)
case ErrorCode.OUTPUT_VALIDATION_FAILED:
case ErrorCode.DELIVERABLE_NOT_FOUND:
return { type: 'OutputValidationError', retryable: true };
// Agent execution - use the retryable flag from the error
case ErrorCode.AGENT_EXECUTION_FAILED:
return { type: 'AgentExecutionError', retryable: retryableFromError };
// Preflight validation errors
case ErrorCode.REPO_NOT_FOUND:
return { type: 'ConfigurationError', retryable: false };
case ErrorCode.AUTH_FAILED:
return { type: 'AuthenticationError', retryable: false };
// Not AuthenticationError: the credential is not in question, and the pipeline
// appends an "is your API key valid" hint to anything classified that way.
case ErrorCode.MODEL_NOT_FOUND:
return { type: 'ModelNotFoundError', retryable: false };
case ErrorCode.MODEL_CONFIG_INVALID:
return { type: 'ModelConfigError', retryable: false };
case ErrorCode.AUTH_LOGIN_FAILED:
return { type: 'AuthLoginFailedError', retryable: false };
case ErrorCode.TARGET_UNREACHABLE:
return { type: 'InvalidTargetError', retryable: false };
default:
return { type: 'UnknownError', retryable: retryableFromError };
}
}
/**
* Classifies errors for Temporal workflow retry behavior.
* Returns error type and whether Temporal should retry.
*
* Used by activities to wrap errors in ApplicationFailure:
* - Retryable errors: Temporal retries with configured backoff
* - Non-retryable errors: Temporal fails immediately
*
* Classification priority:
* 1. A PentestError carrying an ErrorCode is classified by that code.
* 2. Anything else goes through the bounded provider classifier. The original value is passed
* through unchanged so an AssistantMessage keeps the fields the classifier reads.
*/
export function classifyErrorForTemporal(error: unknown): { type: string; retryable: boolean } {
// === CODE-BASED CLASSIFICATION (Preferred for internal errors) ===
if (error instanceof PentestError && error.code !== undefined) {
return classifyByErrorCode(error.code, error.retryable);
}
// === FALLBACK ===
// Credential and configuration failures must surface under their own names and never retry:
// retrying them as transient would burn the whole retry budget on a failure the operator has
// to fix. Everything else becomes a Transient/Permanent marker from the retry verdict.
const failure = classifyProviderFailure(error);
if (failure.type === 'AuthenticationError' || failure.type === 'ConfigurationError') {
return { type: failure.type, retryable: failure.retryable };
}
return { type: failure.retryable ? 'TransientError' : 'PermanentError', retryable: failure.retryable };
}