Error Handling

Error Class Hierarchy

Error
└── MacpSdkError                     Base class for all SDK errors
    ├── MacpTransportError           gRPC connectivity issues (optional .code = gRPC status name)
    │   ├── MacpTimeoutError         stream.read(timeoutMs) elapsed
    │   └── MacpRetryError           retrySend() exhausted its retry budget
    ├── MacpAckError                 Runtime rejected the message (nack)
    ├── MacpSessionError             Client-side payload/session validation failed (invalid session id, vote value, confidence, …)
    └── MacpIdentityMismatchError    Explicit sender conflicts with auth.expectedSender

MacpAckError

Thrown when the runtime returns an Ack with ok: false. Contains the full Ack object:

import { MacpAckError } from 'macp-sdk-typescript';

try {
  await session.vote({ proposalId: 'p1', vote: 'approve' });
} catch (err) {
  if (err instanceof MacpAckError) {
    console.log(err.ack.error?.code);    // e.g., 'SESSION_NOT_OPEN'
    console.log(err.ack.error?.message); // human-readable description
    console.log(err.ack.sessionId);      // session context
    console.log(err.ack.sessionState);   // current session state
  }
}

For structured logging or persistence, err.failure exposes an AckFailure record ({ code, message, sessionId, messageId, reasons }) with the same shape as the Python SDK's MacpAckError.failure. The reasons array is parsed from ack.error.details (or the macp-error-details-bin gRPC trailing metadata) when the runtime attaches per-rule rejection reasons — e.g. policy denials.

Suppressing Auto-Throw

By default, client.send() throws on nack. Disable this to handle rejections manually:

const ack = await client.send(envelope, { raiseOnNack: false });
if (!ack.ok) {
  console.log('rejected:', ack.error?.code);
}

MacpTransportError

Thrown on gRPC connectivity failures:

import { MacpTransportError } from 'macp-sdk-typescript';

try {
  await client.initialize();
} catch (err) {
  if (err instanceof MacpTransportError) {
    console.log('gRPC error:', err.message, err.code);
  }
}

When the underlying failure carried a gRPC status, err.code holds the status name (e.g. RESOURCE_EXHAUSTED for watch-stream consumer lag → reconnect; UNAUTHENTICATED for an auth failure → do not reconnect; FAILED_PRECONDITION for a passive-subscribe resume below a compacted base). It is absent for locally-raised transport errors.

Runtime Error Codes

The MACP runtime uses structured error codes in the Ack. The ones you will hit most often:

CodeMeaning
UNAUTHENTICATEDAuthentication failed
FORBIDDENSender not authorized for this session or message type
SESSION_NOT_FOUNDSession does not exist
SESSION_NOT_OPENSession already resolved, expired, or suspended
INVALID_ENVELOPEEnvelope validation failed or payload structure invalid
POLICY_DENIEDGovernance policy denied the message (e.g. commitment without quorum)
RATE_LIMITEDToo many requests
INTERNAL_ERRORUnrecoverable internal runtime error

The canonical, complete code list lives in the spec's Common Error Codes; the runtime's common errors and SDK guide › error handling cover when each is returned and how to react.

Every code is exported as a string constant from src/constants.ts (e.g. import { SESSION_NOT_OPEN } from 'macp-sdk-typescript'), so comparisons don't need string literals.

Duplicate Handling

The runtime deduplicates messages by message_id. If a duplicate is detected, the Ack returns ok: true with duplicate: true — it is not an error:

const ack = await client.send(envelope);
if (ack.duplicate) {
  console.log('message was already accepted (idempotent)');
}

Patterns

Built-in Retry: retrySend()

The SDK ships a retry helper (src/retry.ts) so you rarely need to hand-roll backoff. retrySend() retries on any MacpTransportError and on NACKs whose code is in retryableCodes (default: RATE_LIMITED, INTERNAL_ERROR), with exponential backoff. Non-retryable NACKs are rethrown immediately; when the budget is exhausted it throws MacpRetryError with the last error as cause:

import { retrySend, DEFAULT_RETRY_POLICY } from 'macp-sdk-typescript';

const ack = await retrySend(client, envelope, {
  policy: { maxRetries: 5, backoffBase: 0.2, backoffMax: 5.0 }, // partial override of DEFAULT_RETRY_POLICY
});

Retry on Transient Errors (manual)

If you need custom semantics, the equivalent hand-rolled loop looks like this:

async function sendWithRetry(
  client: MacpClient,
  envelope: Envelope,
  maxRetries = 3,
): Promise<Ack> {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await client.send(envelope);
    } catch (err) {
      if (err instanceof MacpTransportError && attempt < maxRetries - 1) {
        await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
        continue;
      }
      if (err instanceof MacpAckError && err.ack.error?.code === 'RATE_LIMITED') {
        await new Promise((r) => setTimeout(r, 2000 * (attempt + 1)));
        continue;
      }
      throw err;
    }
  }
  throw new Error('unreachable');
}

Graceful Session Handling

try {
  await session.commit({
    action: 'deployment.approved',
    authorityScope: 'ops',
    reason: 'approved',
  });
} catch (err) {
  if (err instanceof MacpAckError) {
    switch (err.ack.error?.code) {
      case 'SESSION_NOT_OPEN':
        console.log('session already resolved or expired');
        break;
      case 'FORBIDDEN':
        console.log('not authorized to commit');
        break;
      default:
        throw err;
    }
  }
}