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:
co-authored by
Claude Fable 5
parent
001de3c844
commit
250cc7c450
@@ -84,3 +84,50 @@ describe('getStructuredError', () => {
|
||||
expect(result.message_sv.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('retryable contract (always present, transient inference)', () => {
|
||||
it('is explicitly false for a plain unclassified error', () => {
|
||||
const result = getStructuredError(new Error('nope'))
|
||||
expect(result.code).toBe('UNKNOWN_ERROR')
|
||||
expect(result.retryable).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a wrapped deadlock message as TRANSIENT_ERROR, retryable', () => {
|
||||
// Tools wrap DB errors as plain strings, losing the SQLSTATE — the
|
||||
// message pattern must survive that wrapping.
|
||||
const result = getStructuredError(new Error('Database error: deadlock detected'))
|
||||
expect(result.code).toBe('TRANSIENT_ERROR')
|
||||
expect(result.retryable).toBe(true)
|
||||
})
|
||||
|
||||
it('classifies a Postgres serialization-failure SQLSTATE as transient', () => {
|
||||
const err = Object.assign(new Error('could not complete'), { code: '40001' })
|
||||
const result = getStructuredError(err)
|
||||
expect(result.code).toBe('TRANSIENT_ERROR')
|
||||
expect(result.retryable).toBe(true)
|
||||
})
|
||||
|
||||
it('classifies upstream 429/503 statuses as transient', () => {
|
||||
expect(getStructuredError(Object.assign(new Error('slow down'), { status: 429 })).retryable).toBe(true)
|
||||
expect(getStructuredError(Object.assign(new Error('bad gateway'), { statusCode: 502 })).retryable).toBe(true)
|
||||
})
|
||||
|
||||
it('classifies fetch/socket failures as transient', () => {
|
||||
expect(getStructuredError(new Error('fetch failed')).code).toBe('TRANSIENT_ERROR')
|
||||
expect(getStructuredError(new Error('socket hang up')).retryable).toBe(true)
|
||||
expect(getStructuredError(new Error('connect ECONNRESET 1.2.3.4:443')).retryable).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a specific inferred code but still computes retryable from the failure', () => {
|
||||
// NOT_FOUND is permanent even though nothing in the registry says so explicitly.
|
||||
const result = getStructuredError(new Error('Transaction not found'))
|
||||
expect(result.code).toBe('NOT_FOUND')
|
||||
expect(result.retryable).toBe(false)
|
||||
})
|
||||
|
||||
it('lets an explicit registry retryable:true win over inference', () => {
|
||||
const tagged = Object.assign(new Error('over the cap'), { code: 'RATE_LIMITED' })
|
||||
const result = getStructuredError(tagged)
|
||||
expect(result.retryable).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user