Replay-Safe Retries

PraisonAI never silently retries a mid-response failure on a tool-calling turn

On a tool-calling turn, PraisonAI never silently retries a failure that happened after the request was dispatched β€” not from the retry driver, and not from the streaming β†’ non-streaming fallback. The post-dispatch error surfaces to your code so you decide what to do.

Diagram

yes

no

yes

no

yes

no

no

yes

⚠️ Transient failure

Phase signal?
handshake Β· cert verify

πŸ” Retry as before

Post-dispatch signal?
read timeout Β· reset Β· peer closed

Turn has tools?

Pre-dispatch?
DNS Β· connect Β· timeout

🚦 provider_outcome_unknown
surface_error / is_retryable=false

Quick Start

  1. Nothing to configure

    Replay-safety is default-on for tool-calling turns. No new config keys, no new Agent params.

    python
    from praisonaiagents import Agent
    
    agent = Agent(
        name="Bookings",
        instructions="Book flights on request.",
        tools=[book_flight],  # a side-effecting tool
    )
    
    # On a mid-response network failure you'll see:
    #   FailoverDecision(reason='provider_outcome_unknown', is_retryable=False)
    # instead of a silent retry that could book the flight twice.
    agent.start("Book me on the next flight to Tokyo")
  2. Handle the surfaced outcome

    A surfaced provider_outcome_unknown means the provider may already have run your tool β€” you choose whether to verify state, ask the user, or retry deliberately.

    python
    from praisonaiagents import Agent
    
    agent = Agent(
        name="Bookings",
        instructions="Book flights on request.",
        tools=[book_flight],
    )
    
    try:
        agent.start("Book me on the next flight to Tokyo")
    except Exception as e:
        # reason='provider_outcome_unknown' β€” decide, don't blindly replay
        print("Provider outcome unknown; verifying booking state before retrying.")

The Invariant

On a tool-calling turn, a post-dispatch transient failure surfaces as provider_outcome_unknown instead of auto-retrying. Tool-less turns and pre-dispatch failures still auto-retry exactly as before.

Replaying a post-dispatch failure on a tool-calling turn could double-execute the tool β€” double billing, or a re-run of a side-effecting action. The gate stops that.

Diagram
ToolLLM (provider)AgentToolLLM (provider)Agentrequest dispatched…bytes on the wireprovider may already haverun a side-effecting tool callsurfaced to caller β€”no silent retry, no double-executionrequest (tools exposed)1read timeout / connection reset2is_replay_unsafe(err) == True+ side_effecting == True3FailoverDecision(reason=”provider_outcome_unknown”,is_retryable=False)4

Streaming β†’ Non-Streaming Fallback is Gated Too

When streaming fails, PraisonAI used to silently retry the same request with stream=False. On a tool-calling turn that is still a provider replay β€” the tool may already have run server-side mid-response. The fallback now respects the same replay-safety gate.

Diagram
sequenceDiagram
    autonumber
    participant Agent
    participant LLM as LLM (streaming)
    participant Tool

    Agent->>LLM: request (stream=True, tools exposed)
    Note right of LLM: streaming started…<br/>bytes on the wire
    LLM--xAgent: read timeout mid-stream
    Note over Agent: provider may already have<br/>run a side-effecting tool call
    Agent-->>Agent: is_replay_unsafe_chain(err) == True<br/>+ side_effecting == True
    Agent-->>Agent: surface original error
    Note right of Agent: NO second request with stream=False

    classDef agent fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef llm fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef tool fill:#F59E0B,stroke:#7C90A0,color:#fff

    class Agent agent
    class LLM llm
    class Tool tool

Both fallback sites are gated:

SiteWhereWhat was happening beforeWhat happens now
LLM.get_response streaming branchpraisonaiagents/llm/llm.pyuse_streaming = False on any streaming exceptionOn a tool turn (formatted_tools or execute_tool_fn), a replay-unsafe streaming failure re-raises instead of reissuing with stream=False
ChatMixin._stream_fallback_chatpraisonaiagents/agent/chat_mixin.pyReissued chat() unconditionallyOn a tool turn (request tools or instance self.tools), a replay-unsafe streaming failure surfaces instead of reissuing

Tool-less turns and pre-dispatch failures (connect / DNS / TLS) still fall back to non-streaming exactly as before.

Agent example

python
from praisonaiagents import Agent

def book_flight(destination: str) -> str:
    """Side-effecting tool β€” charges a card, calls an API."""
    ...

agent = Agent(
    name="Bookings",
    instructions="Book flights on request.",
    tools=[book_flight],
)

try:
    # Streams by default; if the stream drops mid-response AFTER
    # the request reached the provider, you get the original error
    # instead of a silent stream=False retry that could book twice.
    agent.start("Book me on the next flight to Tokyo", stream=True)
except Exception as e:
    print("Streaming failed mid-response; verifying booking state before retrying.")

What Counts as Post-Dispatch (Replay-Unsafe)

Failures that happen once bytes are on the wire β€” the provider may already have produced output.

SignalException
Read timeouthttpx.ReadTimeout / requests ReadTimeout
Read timeout (urllib3)urllib3.ReadTimeoutError
Connection reset mid-responseConnectionResetError
Truncated bodyChunkedEncodingError
Partial readhttp.client.IncompleteRead
Peer closed mid-responsehttpx.RemoteProtocolError
Protocol error mid-responseurllib3.ProtocolError
Provider request timeout (post-send)APITimeoutError

Message patterns treated as post-dispatch: read timeout, connection reset, reset by peer, peer closed, incomplete read, chunked, response ended prematurely, server disconnected.

What Counts as Pre-Dispatch (Replay-Safe)

Failures that prove the request never reached the provider β€” always safe to replay, retried as before.

SignalException
Connect timeoutConnectTimeout / ConnectTimeoutError
Connection refusedConnectionRefusedError
Connect error (httpx)ConnectError
Connection never establishedNewConnectionError
DNS resolution failuresocket.gaierror
TLS handshake failure (message must mention handshake)ssl.SSLError
Certificate verification failuressl.SSLError (with certificate verify failed / cert verif in message) or ssl.SSLCertVerificationError

Message patterns treated as pre-dispatch: connect timed out, connection refused, name resolution, getaddrinfo, handshake, certificate verify failed, cert verif.

The bare ssl and tls tokens are not pre-dispatch signals β€” ssl.SSLError is raised for both handshake failures and mid-response read/reset failures, so a transport label alone does not establish that the request failed before dispatch. Use the phase-specific handshake or certificate verify message to mark a TLS error as pre-dispatch.

Phase-Specific Precedence

A TLS handshake or certificate verification phase completes before any request byte is written, so an error naming either phase wins over a post-dispatch token.

Example messageClassified asWhy
connection reset by peer during TLS handshakepre-dispatch (safe)handshake phase wins over reset
server disconnected during TLS handshakepre-dispatch (safe)handshake phase wins over disconnected
certificate verify failed; connection resetpre-dispatch (safe)cert-verify phase wins over reset
SSL connection reset by peerpost-dispatch (unsafe)no phase signal β€” generic transport label does not win
TLS transport: read timeoutpost-dispatch (unsafe)no phase signal β€” generic transport label does not win

Building Your Own Retry Wrapper

The classifier is public β€” import it if you write your own retry loop.

python
from praisonaiagents.llm.error_classifier import is_replay_unsafe

try:
    call_provider(...)
except Exception as e:
    if is_replay_unsafe(e) and turn_has_tools:
        raise  # surface β€” do not replay
    # otherwise: safe to retry

is_replay_unsafe(error) returns True when the failure signals the provider may already have processed the request.

Walk the Exception Chain

The streaming path re-raises wrapping the cause (raise Exception(...) from read_timeout), so the replay-unsafe signal lives on __cause__ / __context__ rather than the outermost exception. Use is_replay_unsafe_chain() to walk the chain:

python
from praisonaiagents.llm.error_classifier import is_replay_unsafe_chain

try:
    call_provider_stream(...)
except Exception as e:
    # Even if `e` is a wrapper, is_replay_unsafe_chain finds the
    # read-timeout underneath via __cause__/__context__.
    if is_replay_unsafe_chain(e) and turn_has_tools:
        raise  # surface β€” do not fall back to non-streaming or retry
    # otherwise: safe to retry / fall back
HelperWalks cause chain?Use when
is_replay_unsafe(error)❌You hold the original exception
is_replay_unsafe_chain(error)βœ…The exception may wrap the real signal (streaming paths, re-raise with from)

The chain is treated as unsafe if any link is unsafe β€” a pre-dispatch (safe) link does not override an unsafe one found elsewhere in the chain.


Common Patterns

A TLS-labelled read timeout or mid-response reset on a tool turn now surfaces instead of silently retrying.

python
import ssl
from praisonaiagents.llm.error_classifier import is_replay_unsafe

# Fixed in PR #5443 β€” previously returned False, retrying twice could re-run the tool.
assert is_replay_unsafe(Exception("TLS transport: read timeout")) is True
assert is_replay_unsafe(ssl.SSLError("SSL connection reset by peer")) is True

# Phase-specific pre-dispatch signals still auto-retry safely.
assert is_replay_unsafe(ssl.SSLError("TLS handshake failed")) is False
assert is_replay_unsafe(ssl.SSLError("certificate verify failed")) is False
assert is_replay_unsafe(ssl.SSLError(
    "connection reset by peer during TLS handshake"
)) is False  # phase signal wins over reset token

Best Practices

Treat provider_outcome_unknown as 'verify, then decide'

The provider may already have run your tool. Check downstream state β€” was the flight booked, the charge made β€” before retrying the turn.

Make side-effecting tools idempotent where you can

Idempotency keys turn a risky replay into a safe one. When a tool is idempotent, a deliberate retry after provider_outcome_unknown is cheap.

Only block retries on tool-calling turns

Tool-less completions have no external side effects. Replaying a post-dispatch failure there is safe, so the gate only fires when the turn exposes tools.

Trust the classifier over message text

is_replay_unsafe walks the exception’s class hierarchy first, so provider SDK subclasses are matched even when their messages vary. For paths that re-raise with from (streaming is one), use is_replay_unsafe_chain() so a wrapped cause is still detected.


Related