Repository navigation
Failure hooks: report every failed invocation to the handler SDK, with reason and source - #190
shawnburke wants to merge 6 commits into
Conversation
The agent now reports failures it sees on its own (queue full, expired while queued, no report before the timeout) on a new DispatchEvents stream, and re-queues a failed invocation on RetryInvocation. The queue behind the stream is unbounded so no failure is dropped, and it only exists for clients that set DispatchRequest.failure_events, so older SDKs are unaffected. The Go SDK adds WithPreDispatchHook and WithPostDispatchHook. The post hook runs for every invocation, plus every agent failure event, with a reason (HANDLER_ERROR, TIMEOUT, NO_RESOURCES) and a source (APPLICATION or RUNTIME), and can ask for a retry. A timeout while a Cortex API call is in flight is classed RUNTIME. invokeHandler no longer lets a timed-out handler write to the report while it is being sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e up on Python gets pre_dispatch and post_dispatch on AxonClient, matching Go. Failure events are read on their own thread and run on their own pool, so they arrive even while every handler slot is busy. At a handler's timeout the SDK now reports the timeout itself and drops whatever the handler does afterwards, since a Python thread can't be stopped. The agent now sends each invocation an absolute deadline: when it stops waiting and reports the failure itself. A saturated client used to read invocations out of the stream buffer long after the agent had reported them as failed, and run them anyway, so a failure hook replaying them would have processed them twice. Both SDKs now skip an invocation past its deadline and cap a handler's timeout at it, classing that timeout as RUNTIME. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What this means for handler authorsThe problemWhen a webhook message failed to process, it disappeared without a trace. A message could fail because:
Your code got no signal. The only way to find out was to notice later that data hadn't updated. Even when you did notice, you couldn't tell whether your handler or Axon was to blame. The solutionYou can now register a failure hook with the Axon SDK. Axon calls it for every message that fails, including the ones that never reached your handler. It passes:
What the hook does is up to you: save the message to a topic, a table or a file, or raise an alert. Then tell Axon to drop the message or retry it. Axon doesn't store or replay anything itself; your hook decides what happens. Examplefrom cortex_axon import AxonClient, FailureAction, FailureSource
from cortex_axon.handler import cortex_webhook
# A normal webhook handler. If it raises, the failure hook is called.
@cortex_webhook(id="employee-sync")
def employee_sync(context):
update_records(context.args["body"])
# Called for every message that fails.
def on_failure(info, failure):
# Keep the failed message so it can be replayed later.
dead_letter_topic.publish({
"invocation_id": info.invocation_id,
"handler": info.handler_name,
"attempt": info.attempt,
"reason": failure.reason.name, # HANDLER_ERROR, TIMEOUT or NO_RESOURCES
"source": failure.source.name, # APPLICATION or RUNTIME
"message": info.args, # the original webhook: body, content-type, url
})
# Have Axon try Axon-side failures again (up to 5 attempts); drop the rest.
if failure.source == FailureSource.RUNTIME and info.attempt < 3:
return FailureAction.RETRY
return FailureAction.DROP
AxonClient(scope=globals(), on_failure=on_failure).run()Good to know
|
The post-dispatch hook ran on success too, so every hook began by checking for a failure, and the pre-dispatch hook only made sense paired with that success call. Neither was part of the ask. Python now takes on_failure and Go WithFailureHook; both run only for failures and return FailureAction (IGNORE / RETRY). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both SDKs gain tests that build the agent, run it, and trigger invocations through its webhook endpoint: a handler error and a timeout each reach the failure hook and succeed on retry, and in Python, invocations stuck behind a saturated handler are reported by the agent, retried, and each succeed exactly once without the expired originals running. To support them: the agent's webhook port can be set with WEBHOOK_PORT, and the Python SDK workflow sets up Go to build the agent. The Go SDK's Run(ctx) now stops when ctx is cancelled; it had opened its dispatch stream with context.Background(), and its exit flag was raced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| // ClaimDispatch and ClaimExpired decide, exactly once, whether a queued | ||
| // invocation was sent to a client or expired before one picked it up. | ||
| ClaimDispatch() bool | ||
| ClaimExpired() bool |
There was a problem hiding this comment.
please collapse into SetState(state) so that these are not discrete operations and we have room to grow.
There was a problem hiding this comment.
Done in 23b4a53. ClaimDispatch/ClaimExpired are now one SetState(state) bool plus State(), checked against an explicit transition table (invokeTransitions in handler_invoke.go). Today a queued invocation can move to InvokeDispatched, InvokeExpired or InvokeRejected (new: the queue-full case), and nothing else. A new state is a new constant plus a table entry.
| zap.String("invocation-id", info.InvocationID)) | ||
| return | ||
| } | ||
| resp, err := stub.RetryInvocation(context.Background(), &pb.RetryInvocationRequest{Invoke: info.invoke}) |
There was a problem hiding this comment.
wait why is the SDK deciding when to retry, shouldn't the server do this on its own? this is moving flow control outside of the SDK.
Addresses review feedback: - Invocations have one SetState with an explicit transition table, instead of ClaimDispatch/ClaimExpired. - Failure handling moves out of Manager into FailureManager, which owns the queues, pending answers, metric, retries and retry cap. Retries go back through Manager.Trigger. - One path into it: every failure completes its invocation with a FailureError, and one watcher in Manager hands those to FailureManager. That covers a full queue, expiry in the queue, the agent's own timeout, and failures the SDK reports, which now carry their classification on ReportInvocationRequest.failure. - The send loop is generic: drain() sends whatever an InvocationSource hands out. Dispatch drains Manager and DispatchEvents drains FailureManager. - SDKs no longer call RetryInvocation, which is removed. DispatchEvents is bidirectional: the agent sends each failure, the SDK runs the hook and answers DROP or RETRY on the same stream, and the agent applies it. An event unanswered when the stream ends is sent again on the next one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| zap.String("action", resolution.GetAction().String()), | ||
| ) | ||
| if resolution.GetAction() != pb.FailureAction_FAILURE_ACTION_RETRY { | ||
| logger.Info("Dropping failed invocation") |
There was a problem hiding this comment.
prob should be debug
There was a problem hiding this comment.
Done in 71f8c6f: "Dropping failed invocation" now logs at debug.
|
|
||
| // next moves the oldest event to pending and returns it, waiting up to wait | ||
| // for one. It returns nil if none arrived. | ||
| func (q *failureQueue) next(ctx context.Context, wait time.Duration) *pb.DispatchEvent { |
There was a problem hiding this comment.
why do we need this versus just a q.poll(ctx) -> dispatchEvent? So:
- Wait duration is controlled by Context timeout
- All interested parties can call poll which, if queue is empty, sits on an internal
chan struct{}, then the first one to atomically dequeue an event wins eg callers use a select to do this in a loop - This atomically sets events to pending (or adds them to the map, whatever).
This means we don't need to separate wake and poll, but rather whenever a new item is added to the queue we add an item to the channel and one of the waiters gets it. I think it means we don't need the timer poll either.
The whole next/take thing seems weird. there should just be poll and that return should have a methods like requeue() or close() so the caller doesn't need to stash the invocation ID.
There was a problem hiding this comment.
Agreed, reworked in 71f8c6f along these lines:
- One call.
Dequeue(ctx, dispatchId)waits until there is a failure orctxends. The timer and the separatewakeare gone. - Waiting. While the queue is non-empty, an internal
chan struct{}is closed. Every waiter wakes, the first to take the lock pops the event, and the rest wait again. I used close-to-broadcast rather than one token per item: a buffered channel can't hold a token for every item in an unbounded queue, and the old one-slot version needed the re-wake you flagged. - A handle, not an id.
Dequeuereturns a*FailureEventwithResolve(action)andRequeue(), and only the first call takes effect. The queue no longer keeps a pending map, sonext/takeare gone.DispatchEventsstill keeps the handles by invocation id, because the client's answer arrives on the stream with only that id.
| // InvocationSource hands out work for one dispatch id. Next waits up to wait | ||
| // and returns the zero value when nothing arrived in that time. | ||
| type InvocationSource[T comparable] interface { | ||
| Next(ctx context.Context, dispatchId string, wait time.Duration) (T, error) |
There was a problem hiding this comment.
From above It seems like this is just Dequeue(ctx, dispatchId) (event, error) which is more idomatic Go
There was a problem hiding this comment.
Done: InvocationSource[T] is now Dequeue(ctx, dispatchId) (T, error). When the context ends it returns ctx.Err(), and the zero-value check is gone. Manager and FailureManager both implement it. drain adds a per-call timeout only for Dispatch, which needs a periodic idle check to notice when every handler has finished.
- InvocationSource is Dequeue(ctx, dispatchId); ctx bounds the wait, and drain only adds a timeout where it needs an idle check. - FailureManager.Dequeue returns a *FailureEvent with Resolve(action) and Requeue(). The queue no longer tracks pending events by id, and has no wake helper or poll timer: waiters block on a channel that is closed while the queue is non-empty, and the first to take the lock wins. - Dropped failures log at debug. - HandlerInvoke methods use pointer receivers: the value copy raced with Complete. Lock the dispatch queue map, which webhooks and client streams both use. - e2e tests pick distinct free ports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Adds a failure hook to the Go and Python handler SDKs (
WithFailureHook/on_failure) that fires for every failed invocation. The hook receives the original message and a reason that separates handler failures from Axon failures. A customer can then route failed webhooks to a store of their own choosing and replay them later. Before this change, those messages failed silently. Tracks COR2-1098.The three failure cases asked for, and what the hook receives for each:
reasonsourceHANDLER_ERRORAPPLICATIONTIMEOUTAPPLICATION, orRUNTIMEif it was waiting on a Cortex API call or for a free handlerTIMEOUTRUNTIMENO_RESOURCESRUNTIME,caller_rejected=trueNO_RESOURCESRUNTIMEEvery failure, wherever it is detected, goes through the agent. The agent sends it to the SDK's hook, the hook answers drop or retry, and the agent acts on the answer. Each failure is delivered once per invocation; one the hook hadn't answered when its connection dropped is sent again.
Python usage
Design
Flow
Agent
One path into
FailureManager. Every agent-visible failure completes its invocation with aFailureError, and a single watcher inManager(watch) hands it to theFailureManager. The four sources are:ReportInvocationRequest.failure;completeInvocation;An error reported without
failure, as older SDKs send them, is only recorded.FailureManager(handler/failure_manager.go) owns everything failure-related:axon_handler_failure_events_pendingmetric;Manager.Trigger, and the 5-attempt cap.It queues only for clients that set
DispatchRequest.failure_events.Generic sources and a simple send loop.
InvocationSource[T]has one method,Dequeue(ctx, dispatchId), and bothManagerandFailureManagerimplement it. A failure is handed out as a*FailureEventhandle, and the stream calls itsResolve(action)when the answer arrives, orRequeue()if the stream ends first.drain()sends whatever a source hands out:DispatchdrainsManager, andDispatchEventsdrainsFailureManager. They stay separate streams because the Python SDK stops readingDispatchwhile all its handler slots are busy, which is exactly when failures happen.Invocation state. A single
SetStatewith an explicit transition table. A queued invocation moves once, to dispatched, expired or rejected. Once dispatched, it ends only by a report or by the agent's timeout, not by its queue timeout.Deadline. Each dispatched invocation carries an absolute
deadline: when the agent stops waiting and reports the failure itself (timeout + 5s grace).SDKs
They only report and answer. The SDKs don't run the hook for their own failures and don't call any retry RPC. They classify the failure into
ReportInvocationRequest.failure, which covers:APPLICATIONvsRUNTIME;RUNTIME.The hook runs only for events from the agent, and its return value is sent back as the answer.
Python:
on_failure=, returningFailureAction.DROP/RETRY. ReturningNoneor raising means DROP. Events are read on their own thread and run on their own pool, never waiting for a handler slot. At the deadline the SDK reports the timeout itself and drops anything the handler does afterwards, since a thread can't be stopped.Go:
WithFailureHook, returningFailureActionDrop/FailureActionRetry.Fixes found along the way:
invokeHandlerlet a timed-out handler write to the report while it was being sent.Run(ctx)ignored cancellation: it opened its stream withcontext.Background(), and its exit flag was raced.Test support: the agent's webhook port can be set with
WEBHOOK_PORT.Compatibility
failure_events, so nothing is queued, and it ignores the new fields.DispatchEventsreturnsUnimplemented, so the SDK logs once and stops asking, without redialing in a loop. The hook doesn't fire, because failures are delivered by the agent.DispatchEventsand the failure messages are new in this PR, so changing them breaks no released contract.Known limits
invocation_idtwice.TIMEOUT/RUNTIMErather thanNO_RESOURCES. The source is still right.Test plan
go test ./...,make test-race):TIMEOUT→ answered RETRY → attempt 2;caller_rejectedfailure.go test -race ./...):Unimplementedagent is not redialed;failure_eventsflag.ruffclean):APPLICATIONand its late result is dropped;RUNTIME;agent/and triggered over its webhook endpoint:sdks/go/e2e_failure_test.go, skipped with-short): a handler error and a timeout each go SDK report → agent → hook → RETRY, the retry succeeds with the original body, and the failure is not delivered twice.tests/test_e2e_failures.py): the same two cases, plus saturation. One slot is stuck, the agent reports the queued invocations, the hook retries them, and each message succeeds exactly once, with no expired original run. The Python workflow now sets up Go to build the agent; without Go the tests skip.DRYRUN) with the Python SDK and the HTTP webhook endpoint:HANDLER_ERROR/APPLICATION, then a successful retry as attempt 2.TIMEOUT/APPLICATION, and the late result was dropped.🤖 Generated with Claude Code