feat(mcp): always-explicit retryable on structured errors + transient inference (P1-1) (#875)

Agents could not distinguish 'keep retrying' from 'stop, this is
broken' (agent.feedback): retryable was emitted only when a registry
entry declared true — absent otherwise, including for genuinely
transient DB/network failures whose SQLSTATE is lost when tools wrap
them as Error('Database error: ...').

- StructuredError.retryable is now a required boolean. Registry
  declaration wins; otherwise isTransientFailure() infers from Postgres
  SQLSTATEs (40001/40P01/57014/08xxx/53xxx/55P03), upstream HTTP
  statuses (408/429/5xx), and message signatures that survive wrapping
  (deadlock, serialization, statement timeout, fetch/socket failures).
- Unclassified transient failures surface as stable code
  TRANSIENT_ERROR (new registry entry, retryable: true).
- categorize_transaction accepts idempotency_key — the tool agents
  blind-retry after client-side approval-elicitation drops; the key
  makes that retry replay-safe instead of double-staging.
- Contract documented in .claude/rules/mcp-server.md. The planned
  'kind' field was dropped: the code registry already encodes it;
  retryable is the agent-actionable bit.

Every tool error already flows through the single dispatch point
(toToolError -> getStructuredError), so coverage is universal without
per-tool migration. Full unit suite: 6575 tests green.

Part of dev_docs/mcp_optimization_plan.md (P1-1).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-03 10:29:58 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 001de3c844
commit 250cc7c450
6 changed files with 135 additions and 10 deletions
+61 -6
View File
@@ -51,11 +51,60 @@ export interface StructuredError {
message_en: string
remediation?: StructuredErrorRemediation
/**
* Present (true) only when the failure is transient. Agents may retry the
* same request after a short backoff. Absent or false means the request
* will fail the same way until inputs or system state change.
* ALWAYS present. `true` → transient failure: the identical request may
* succeed after a short backoff (pair with idempotency_key on staging
* tools). `false` → permanent for these inputs: retrying is wasted work
* until arguments or system state change. From the registry entry when the
* code declares it; otherwise inferred by isTransientFailure().
*/
retryable?: boolean
retryable: boolean
}
// Postgres SQLSTATEs that indicate a transient condition — the same statement
// can succeed on retry without any input change.
const TRANSIENT_SQLSTATES = new Set([
'40001', // serialization_failure
'40P01', // deadlock_detected
'57014', // query_canceled (statement timeout)
'57P03', // cannot_connect_now
'53300', // too_many_connections
'53400', // configuration_limit_exceeded
'55P03', // lock_not_available
'08000', // connection_exception
'08003', // connection_does_not_exist
'08006', // connection_failure
])
const TRANSIENT_HTTP_STATUSES = new Set([408, 429, 502, 503, 504, 522, 524])
// Message-level signatures for transient failures. Tools commonly wrap DB
// errors as plain `Error(\`Database error: ${message}\`)`, losing the
// SQLSTATE — these patterns survive that wrapping.
const TRANSIENT_MESSAGE_PATTERNS = [
/fetch failed/i,
/ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|EAI_AGAIN/,
/socket hang up/i,
/deadlock detected/i,
/could not serialize access/i,
/canceling statement due to statement timeout/i,
/connection terminated/i,
/too many clients/i,
/rate limit/i,
/timed out/i,
]
function isTransientFailure(error: unknown, message: string): boolean {
if (typeof error === 'object' && error !== null) {
const obj = error as Record<string, unknown>
const inner = typeof obj.error === 'object' && obj.error !== null ? (obj.error as Record<string, unknown>) : undefined
for (const c of [obj.code, inner?.code]) {
if (typeof c === 'string' && TRANSIENT_SQLSTATES.has(c)) return true
}
for (const s of [obj.status, obj.statusCode]) {
if (typeof s === 'number' && TRANSIENT_HTTP_STATUSES.has(s)) return true
}
}
return TRANSIENT_MESSAGE_PATTERNS.some((re) => re.test(message))
}
interface StructuredErrorOptions {
@@ -138,7 +187,11 @@ export function getStructuredError(
const message_en = extractEnglishMessage(error)
const message_sv = getErrorMessage(error)
const code = extractCode(error) ?? inferCode(message_en) ?? 'UNKNOWN_ERROR'
const transient = isTransientFailure(error, message_en)
let code = extractCode(error) ?? inferCode(message_en) ?? 'UNKNOWN_ERROR'
// Nothing more specific matched but the failure is transient — surface the
// stable TRANSIENT_ERROR code so agents can dispatch on it.
if (code === 'UNKNOWN_ERROR' && transient) code = 'TRANSIENT_ERROR'
const entry = getErrorEntry(code)
let remediation = entry?.remediation
@@ -156,7 +209,9 @@ export function getStructuredError(
message_sv,
message_en,
...(remediation ? { remediation } : {}),
...(entry?.retryable ? { retryable: true } : {}),
// Registry declaration wins; otherwise the transient inference decides.
// Always explicit — agents must never have to distinguish absent from false.
retryable: entry?.retryable ?? transient,
}
}