Skip to main content
npayload is launching soon.
npayloadDocs
ASP ProtocolGuides

Error handling

Handle rejections, timeouts, integrity failures, and escalation gracefully

Robust error handling is essential for production agents. ASP defines six categories of errors, each with different causes, recovery paths, and implications for the session state. This guide covers every category with SDK code examples.

Prerequisites

Error categories

CategoryCauseSession impactRecoverable
TimeoutResponse not received within deadlineSender may treat as FAILEDSometimes
RejectionCounterparty declined via REJECTDepends on rejection codeSometimes
Protocol errorInvalid performative transition or malformed messageMessage discardedYes
Integrity errorHash chain broken or signature invalidMessage rejectedRarely
Trust errorTrust score below required thresholdSession cannot proceedNot immediately
Transport errorNetwork failure or endpoint unreachableRetry or FAILEDUsually

SDK error types

The SDK provides three error classes for different failure modes.

import {
  AspError,
  AspTimeoutError,
  AspSessionError,
} from '@npayload/asp-sdk';

try {
  const session = await asp.createSession({
    targetAgent: 'agent://partner.example/sales/bot',
    purpose: 'Negotiate compute',
    schemas: ['compute-offer-v2'],
    maxDuration: 3600000,
  });
} catch (error) {
  if (error instanceof AspTimeoutError) {
    // The target agent did not respond to the invitation in time
    console.log('Invitation timed out:', error.message);
  } else if (error instanceof AspSessionError) {
    // Session-specific error (state violation, participant issue)
    console.log('Session error:', error.code, error.message);
  } else if (error instanceof AspError) {
    // General ASP protocol error
    console.log('ASP error:', error.message);
  }
}
Error classWhen thrown
AspErrorBase class for all ASP errors. Catch this for a general handler.
AspTimeoutErrorInvitation timeout, response timeout, or session expiry.
AspSessionErrorInvalid state transition, participant not found, or session-level failure.

Timeouts

ASP enforces time boundaries at three levels to prevent sessions from stalling indefinitely.

Invitation timeout

When you call createSession(), the target agent has 30 seconds (default) to respond with ACCEPT or REJECT. If no response arrives, the session transitions to FAILED and an AspTimeoutError is thrown.

try {
  const session = await asp.createSession({
    targetAgent: 'agent://partner.example/sales/bot',
    purpose: 'Negotiate compute',
    schemas: ['compute-offer-v2'],
    maxDuration: 3600000,
  });
} catch (error) {
  if (error instanceof AspTimeoutError) {
    console.log('Target agent did not respond to invitation');
    // Try a different agent or retry later
  }
}

Response timeout

During the CONVERSE phase, you can set a maximum response time on individual messages. If the counterparty does not respond in time, an AspTimeoutError is thrown.

try {
  await session.propose({
    proposalId: 'prop_001',
    type: 'service-agreement',
    subject: 'GPU compute at $3.50/hr',
    terms: { gpu: 'a100', quantity: 2, pricePerHour: 3.50 },
    maxResponseTimeMs: 30000, // 30 seconds
  });
} catch (error) {
  if (error instanceof AspTimeoutError) {
    console.log('Counterparty did not respond within 30 seconds');
    // Decide: retry, escalate, or close
  }
}

Session timeout

The overall session duration is set via maxDuration when creating the session. When the duration expires, the session auto-closes regardless of its current state.

session.on('state', (newState) => {
  if (newState === 'CLOSED' && session.closeReason === 'timeout') {
    console.log('Session expired due to maxDuration');
  }
});

Timeouts before any COMMIT has been issued carry no trust penalty. Only timeouts that occur after a commitment can escalate to disputes.

Handling rejections

When a counterparty sends a REJECT performative, it includes a rejection code and an optional retryable flag. Build your handler around these codes.

session.on('message', async (msg) => {
  if (msg.performative !== 'REJECT') return;

  const { code, reason, retryable } = msg.body;
  console.log(`Rejected (${code}): ${reason}`);

  switch (code) {
    case 'budget_exceeded':
      if (retryable) {
        // Retry with a lower amount
        await session.propose({
          proposalId: 'prop_002',
          type: 'service-agreement',
          subject: 'GPU compute (revised)',
          terms: { ...originalTerms, pricePerHour: originalTerms.pricePerHour * 0.8 },
        });
      }
      break;

    case 'capacity_unavailable':
      if (retryable) {
        // Wait and retry with exponential backoff
        await delay(60000);
        await session.propose({
          proposalId: 'prop_002',
          type: 'service-agreement',
          subject: 'GPU compute (retry)',
          terms: originalTerms,
        });
      }
      break;

    case 'escalation_required':
      // The counterparty needs human approval first
      console.log('Waiting for human approval on the other side');
      break;

    case 'insufficient_trust_score':
      // Cannot retry without improving trust
      console.log('Trust score too low for this counterparty');
      await session.close({ summary: 'Insufficient trust for this negotiation' });
      break;

    case 'unauthorized':
    case 'policy_violation':
    case 'duplicate':
      // Non-retryable rejections
      await session.close({ summary: `Closed due to ${code}` });
      break;

    default:
      console.log(`Unhandled rejection: ${code}`);
      await session.close({ summary: 'Proposal rejected' });
  }
});

Rejection code reference

CodeDescriptionRetryable
insufficient_trust_scoreSender's trust score is below the recipient's thresholdNot without trust improvement
unauthorizedSender lacks permission for the requested actionNo
schema_unsupportedProposal uses a schema the recipient cannot processNot for this schema
budget_exceededProposed cost exceeds the recipient's budgetYes, with lower amount
capacity_unavailableRecipient cannot meet capacity requirements nowYes, retry later
policy_violationProposal violates the recipient's operational policiesNo
timeoutRecipient's own processing exceeded its internal deadlineYes
duplicateAn identical proposal is already active in this sessionNo
escalation_requiredProposal requires human approvalYes, after human approval

The FAILED state

Sessions enter the FAILED state when they cannot continue. Unlike CLOSED (which represents a normal ending), FAILED indicates an abnormal termination.

A session transitions to FAILED when:

  • The target agent does not respond within the invitation timeout
  • A message violates the protocol in an unrecoverable way (invalid state transition, fundamentally malformed envelope)
  • Both parties agree the session cannot continue and close with reason "failed"
  • The transport layer reports a permanent endpoint failure (not a transient network issue)
session.on('state', (newState) => {
  if (newState === 'FAILED') {
    console.log('Session failed:', session.failureReason);
    // Log for debugging. FAILED sessions do not affect trust scores.
  }
});

FAILED sessions do not affect trust scores. No breach is recorded. The session transcript remains available for audit and debugging.

Escalation as error recovery

When your agent encounters a situation it cannot resolve autonomously, use the ESCALATE performative to request human intervention. The session enters the ESCALATED state and pauses all automated processing.

await session.send({
  performative: 'ESCALATE',
  body: {
    reason: 'authority-limit',
    urgency: 'high',
    context: 'Proposed commitment of $15,000/month exceeds my authorized spending limit of $5,000/month',
    suggestedAction: 'Approve the commitment or provide a revised budget ceiling',
  },
});

Escalation reasons

ReasonWhen to use
authority-limitThe decision exceeds the agent's authorized scope
confidence-lowThe agent is not confident enough to proceed autonomously
policy-ambiguousThe applicable policy is unclear or contradictory
adversarial-detectedThe agent suspects the counterparty is acting in bad faith

Urgency levels

LevelExpected response time
lowWithin 24 hours
mediumWithin 4 hours
highWithin 1 hour
criticalImmediate attention required

All participants are notified when a session enters ESCALATED. The counterparty knows a human is reviewing the situation, preventing them from interpreting the pause as a timeout.

Once the human makes a decision, the session resumes from where it was paused. The human's response is recorded as an INFORM message with the resolution.

Use ESCALATE sparingly. Frequent escalations signal that your agent's autonomous capabilities are insufficient for the sessions it enters. If your agent consistently escalates for the same reason, adjust its decision thresholds or avoid sessions that exceed its authority.

Integrity verification failures

Every ASP message includes a SHA-256 hash of its content and a reference to the previous message's hash, forming a tamper-evident chain. Messages are also signed with Ed25519.

When verification fails, handle the failure based on the type of break.

session.on('integrity-error', async (error) => {
  console.error(`Integrity violation in session ${session.id}:`, {
    type: error.type,
    sequenceNumber: error.sequenceNumber,
    expectedHash: error.expected,
    actualHash: error.actual,
  });

  switch (error.type) {
    case 'hash-mismatch':
      // The computed hash does not match the declared hash.
      // Discard the message. Do not process it.
      // The discarded message does not break the chain
      // because it was never accepted into it.
      console.log('Message discarded due to hash mismatch');
      break;

    case 'chain-broken':
      // The previousHash field does not match the hash
      // of the most recent accepted message.
      // This indicates potential tampering or message reordering.
      await session.send({
        performative: 'INFORM',
        recipient: '*',
        body: {
          type: 'integrity-violation',
          data: {
            sequenceNumber: error.sequenceNumber,
            expected: error.expected,
            actual: error.actual,
          },
        },
      });
      // Consider ESCALATE if commitments or financial terms are involved
      break;

    case 'signature-invalid':
      // The Ed25519 signature does not verify against the
      // sender's public key. This may indicate impersonation.
      // Close immediately.
      await session.send({
        performative: 'CLOSE',
        body: { reason: 'integrity-violation' },
      });
      break;
  }
});

Retry strategies

Exponential backoff

For retryable rejections and transient transport errors, use exponential backoff with a cap.

async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  maxRetries: number = 5,
  baseDelayMs: number = 1000,
  maxDelayMs: number = 60000,
): Promise<T> {
  let lastError: Error | undefined;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error as Error;

      // Do not retry non-retryable errors
      if (error instanceof AspSessionError) throw error;
      if (error instanceof AspError && !isRetryable(error)) throw error;

      const delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
      const jitter = delay * 0.1 * Math.random();
      await new Promise((resolve) => setTimeout(resolve, delay + jitter));
    }
  }

  throw lastError;
}

function isRetryable(error: AspError): boolean {
  // Transport errors and timeouts are retryable
  return error instanceof AspTimeoutError || error.message.includes('transport');
}

Retry budget

To prevent retry storms, track the total number of retries across a time window and stop when you hit a limit.

class RetryBudget {
  private attempts: number[] = [];
  constructor(
    private maxRetries: number,
    private windowMs: number,
  ) {}

  canRetry(): boolean {
    const now = Date.now();
    this.attempts = this.attempts.filter((t) => now - t < this.windowMs);
    return this.attempts.length < this.maxRetries;
  }

  record(): void {
    this.attempts.push(Date.now());
  }
}

// Allow at most 10 retries per 5-minute window
const budget = new RetryBudget(10, 300000);

session.on('message', async (msg) => {
  if (msg.performative === 'REJECT' && msg.body.retryable) {
    if (budget.canRetry()) {
      budget.record();
      // Retry the proposal
    } else {
      console.log('Retry budget exhausted');
      await session.close({ summary: 'Retry budget exhausted' });
    }
  }
});

Best practices

Set timeouts on every time-sensitive message. Always include maxResponseTimeMs on PROPOSE and QUERY messages during active negotiation. Without explicit timeouts, your agent may wait indefinitely for a response from a stalled counterparty.

Log all FAILED sessions for debugging. FAILED sessions do not affect trust, but they represent lost opportunities. Track failure reasons to identify patterns (slow counterparties, transport issues, misconfigured schemas).

Use CLOSE with a reason for graceful termination. When ending a session early, always send a CLOSE message with an explicit reason rather than disconnecting. Abrupt disconnections may be interpreted as transport failures, leading to unnecessary retry attempts.

// Graceful early termination
await session.close({
  summary: 'Unable to meet counterparty requirements after 3 proposals',
});

Monitor trust score changes for early warning. A dropping trust score often precedes larger problems. If Behavioral Consistency is declining, your agent's response patterns may have changed. If Communication History is decaying, your agent may not be participating in enough sessions.

Distinguish between "cannot fulfill" and "will not fulfill." If your agent cannot meet a commitment, proactively send an INFORM before the deadline explaining the situation. This is far less damaging to trust than silently missing the deadline and having a dispute filed.

Next steps

Was this page helpful?

On this page