A2A Task Idempotency and Recovery: Duplicates, Dropped Streams, and Stalled Tasks

A2A Task Idempotency and Recovery: Duplicates, Dropped Streams, and Stalled Tasks

Networks drop, clients retry, processes restart. The protocol does not absorb these failures for you: it provides messageId, contextId, and two recovery channels, and leaves exactly-once effects to the implementation. This guide shows the patterns that put those three to work, plus recovery probes you can run repeatedly.

State and operation semantics are on the Task object page and in the Task runtime guide ; the release checklist for restart and retries is section 5 of the production readiness guide .

Recovery decision tree

After a dropped connection or a response timeout, the client first determines where the task went, then acts:

stream dropped / response timeout
  ├─ already terminal (COMPLETED / FAILED / CANCELED / REJECTED)?
  │    └─ yes: GetTask for the terminal state and artifacts, done
  ├─ subscription available (capabilities.streaming)?
  │    └─ yes: SubscribeToTask and continue from the event stream
  │         └─ when the server cannot resume from an arbitrary point,
  │            trust the events received and deduplicate repeats by
  │            sequence number (below)
  ├─ polling only?
  │    └─ GetTask with backoff until terminal or stalled
  └─ stalled: still SUBMITTED / WORKING past the business cap
       ├─ client: CancelTask, or resend the request in a new task (same contextId)
       └─ server: move the task to FAILED with a stated reason, never WORKING forever

Do not read “the stream ended” as “the task ended”: a dropped connection only means you can no longer see it; the task may still be progressing server-side.

1. Duplicate messages: messageId is the idempotency key

messageId is generated by the sender — the protocol’s hook for idempotency: on a timeout the client resends with the same messageId, and the server, recognizing a processed ID, returns the original result instead of re-running the work.

ScenarioClient behaviorExpected server behavior
Resending the same message after a timeoutResend verbatim; messageId unchangedRecognize the duplicate; return the first processed result
Continuing in the same conversation (same task)New messageId + taskId (or contextId)Advance the task with the new input
Follow-up after a task endedSame contextId, new message without taskIdCreate a new task (terminal tasks cannot restart)
Duplicate message to a terminal taskResend with the same messageIdReturn the result idempotently, or handle as defined

Implementation notes:

  1. The server uses (taskId or contextId, messageId) as the idempotency key, storing a pointer to the first outcome (task ID, response, or error);
  2. The idempotency window spans the whole period “before the client gives up”, not a few seconds;
  3. Business side effects (external calls, database writes) happen after the key is registered, closing the gap between “receipt not persisted” and “side effect already happened”;
  4. For idempotency across messages, use contextId: new tasks in the same context can carry an upstream business key whose dedup scope your implementation defines.

The protocol does not guarantee exactly-once at the business level — referenceTaskIds and contextId carry correlation only; dedup policy is yours. Section 5 of the production readiness guide lists the same boundary.

2. Duplicate delivery: three dedup points on the receiving side

Three places on the recovery path can see “the same event a second time”:

LocationDedup key
Streamed events (after reconnect)Event sequence / the lastChunk boundary of TaskArtifactUpdateEvent
Push notificationsTask ID + state; streamed notifications by event sequence (section 4 of Push receiver hardening )
Artifact chunksartifactId + append / lastChunk; repeated chunks overwrite or skip

Until lastChunk is true, chunks of the same artifactId may all be retransmissions; clients aggregate by artifactId and assume nothing about chunk counts.

3. Restart recovery: give accepted tasks an outcome

After a server restart, accepted tasks have only two acceptable outcomes: continue per the product contract, or fail with an explicit result. The pattern:

startup -> load persisted task records
  ├─ task with a registered idempotency key -> replay the recorded result, never re-run side effects
  ├─ task already executing -> resume, or move to FAILED (reason: "server restart")
  └─ unrecoverable -> FAILED with a readable reason; never parked back in WORKING
       waiting for an executor that will not come

Write “where tasks go after a restart” into the product contract so clients can decide between retrying and giving up; this is item 3 of the checklist in section 5 of the production readiness guide .

4. Recovery probes

Two probes verify “duplicates do no double work” and “terminal state survives a dropped stream”. They sit on the trace validator from section 4 of the Task runtime guide and list only the recovery assertions:

#!/usr/bin/env bash
set -euo pipefail

: "${A2A_BASE_URL:?Set A2A_BASE_URL}"
: "${A2A_VALID_TOKEN:?Set A2A_VALID_TOKEN}"

send() { # body -> response
  curl --silent -H "Authorization: Bearer $A2A_VALID_TOKEN" \
    -H 'Content-Type: application/json' -d "$1" "$A2A_BASE_URL"
}

# Probe 1 — duplicate message is idempotent:
#   send the same message twice with one messageId; the observable task
#   artifacts must not double. Pick a task whose artifact count is stable.
MSG_ID="probe-$$-$(date +%s)"
BODY="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"message/send\",\"params\":{\"message\":{\"role\":\"role_user\",\"parts\":[{\"kind\":\"text\",\"text\":\"count words in: one two three\"}],\"messageId\":\"$MSG_ID\",\"contextId\":\"probe-ctx-$MSG_ID\"}}}"

first="$(send "$BODY")"
second="$(send "$BODY")"

task_id="$(printf '%s' "$first" | jq -r '.result.taskId // empty')"
[[ -n "$task_id" ]] || { printf 'FAIL first send did not create a task\n' >&2; exit 1; }
first_count="$(printf '%s' "$first" | jq '[.result.artifacts[]?] | length')"
second_count="$(printf '%s' "$second" | jq '[.result.artifacts[]?] | length')"
[[ "$first_count" == "$second_count" ]] \
  || { printf 'FAIL duplicate message produced extra artifacts (%s vs %s)\n' "$first_count" "$second_count" >&2; exit 1; }
printf 'OK   duplicate message is idempotent (taskId=%s, artifacts=%s)\n' "$task_id" "$first_count"

# Probe 2 — terminal state survives a dropped stream:
#   1. start a streaming task, read the first event, then kill the connection
#   2. SubscribeToTask (or GetTask when streaming is unavailable)
#   3. assert the task reaches a terminal state with the same taskId

Probe 2 requires cutting a streaming connection (curl --max-time 1 exiting after the first event) and reconnecting per the decision tree; full event-order assertions belong to the trace validator — here we assert only that a terminal state is reachable and the task ID is unchanged. Both probes are repeatable: they are themselves idempotent.

5. Observability: make stalls visible

  • Stall alerts: the count of tasks still WORKING past the business P99 duration without events; on trigger, include the task-age distribution and the latest event;
  • Terminal distribution: COMPLETED / FAILED / CANCELED / REJECTED ratios, FAILED clustered by reason;
  • Duplicate rate: the share of messages hitting the idempotency key — it reflects both network quality and client retry policy;
  • Recovery metrics: the share of reconnects that retrieve a terminal state, and the number of tasks finalized (rather than left hanging) after a restart.

Field naming follows the logging conventions in section 4 of the production readiness guide ; task IDs are for correlation only, never for authorization.

Related pages

Version and verification notes

Aligned with A2A v1.0.1. Names such as message/send, role_user, taskId, contextId, and lastChunk follow the data structures ; probe 1 was verified against an example endpoint for the idempotent behavior class, while probe 2’s reconnect assertions depend on the site’s streaming implementation and belong to deployment-time checks. Stall thresholds and idempotency-window durations are deployment parameters; this page requires only that they exist and are observable.

Sources: A2A data structures , Life of a task .