MACP

Error Handling and Retry

Exception hierarchy

All SDK exceptions derive from MacpSdkError:

MacpSdkError                    Base exception
├── MacpAckError                Runtime rejected the message (NACK)
├── MacpSessionError            Session-level error (wrong state, not started)
└── MacpTransportError          gRPC communication failure
    ├── MacpTimeoutError        Operation timed out
    └── MacpRetryError          All retry attempts exhausted

Handling NACKs

When the runtime rejects a message, the SDK raises MacpAckError with a structured AckFailure:

from macp_sdk import MacpAckError

try:
    session.vote("p1", "approve", sender="alice")
except MacpAckError as e:
    print(e.failure.code)        # e.g., "FORBIDDEN"
    print(e.failure.message)     # e.g., "sender not authorized for mode"
    print(e.failure.session_id)  # session context
    print(e.failure.message_id)  # message that was rejected

Error code categories

Permanent errors — Do not retry. Fix the underlying issue.

CodeCauseAction
UNAUTHENTICATEDBad token or missing credentialsCheck AuthConfig
FORBIDDENSender not authorized for this mode/sessionCheck token permissions
SESSION_NOT_FOUNDSession doesn't existVerify session_id
SESSION_NOT_OPENSession already resolved or expiredCheck session state
INVALID_ENVELOPEMalformed envelope or payloadFix message construction
MODE_NOT_SUPPORTEDRuntime doesn't support this modeCheck client.list_modes()
PAYLOAD_TOO_LARGEExceeds max payload size (default 1MB)Reduce payload
INVALID_SESSION_IDSession ID format invalidUse new_session_id()
UNSUPPORTED_PROTOCOL_VERSIONVersion mismatchUpdate SDK

Transient errors — Safe to retry with backoff.

CodeCauseAction
RATE_LIMITEDPer-sender rate limit exceededRetry with backoff
INTERNAL_ERRORRuntime internal failureRetry with backoff

Duplicate detection

A duplicate message_id returns ok=true, duplicate=true — not an error. The SDK's session helpers generate unique message_id values automatically. If you're building custom envelopes, ensure uniqueness.

Retry with backoff

The SDK provides retry_send() for automatic exponential backoff:

from macp_sdk import RetryPolicy, retry_send

# Default policy: 3 retries, 0.1s base backoff, retries RATE_LIMITED and INTERNAL_ERROR
retry_send(client, envelope, auth=auth)

# Custom policy for high-throughput workloads
policy = RetryPolicy(
    max_retries=5,
    backoff_base=0.5,        # first retry after 0.5s
    backoff_max=10.0,        # cap at 10s between retries
    retryable_codes=frozenset({"RATE_LIMITED", "INTERNAL_ERROR"}),
)
retry_send(client, envelope, policy=policy, auth=auth)

retry_send raises MacpRetryError (subclass of MacpTransportError) when all attempts are exhausted.

!!! warning "Session helpers don't retry automatically" session.vote(), session.propose(), etc. do not retry on failure. They call client.send() once. If you need retry behavior, build custom envelopes and use retry_send(), or wrap the session helper call in your own retry logic.

Transport errors

MacpTransportError is raised when gRPC communication fails entirely (network down, server unreachable, connection reset):

from macp_sdk import MacpTransportError

try:
    client.initialize()
except MacpTransportError as e:
    print(f"Cannot reach runtime: {e}")

Timeout handling

Set timeouts at the client level or per-operation:

# Client-level default timeout
client = MacpClient(target="...", default_timeout=10.0, ...)

# Per-operation override
response = client.get_session(session_id, timeout=5.0)

When a timeout occurs, gRPC raises an error that the SDK translates to MacpTransportError.

Graceful degradation patterns

Check session state before acting

# Query metadata before sending messages to a potentially stale session
meta = session.metadata()
if meta.metadata.state == core_pb2.SessionState.OPEN:
    session.vote("p1", "approve", sender="alice")
else:
    print(f"Session is {meta.metadata.state}, skipping vote")

Handle already-resolved sessions

try:
    session.vote("p1", "approve", sender="alice")
except MacpAckError as e:
    if e.failure.code == "SESSION_NOT_OPEN":
        # Session resolved or expired while we were preparing
        print("Session already concluded")
    else:
        raise

Cancellation

Cancel a session that should not proceed:

session.cancel(reason="coordinator decided to abort")

This transitions the session to EXPIRED. Already-resolved sessions cannot be cancelled.