Skip to content

Scheduled job sends concurrent settlements causing batch nonce conflicts #86

@whoabuddy

Description

@whoabuddy

Problem

Payment settlement errors from x402-api-host cluster at exact 4-hour intervals with 3-5 failures per burst, all from concurrent requests hitting the relay simultaneously. This pattern is consistent with a scheduled/cron job that fires multiple API calls at once.

Evidence from production logs (2026-03-25)

Error pattern — exact 4-hour intervals

Time (UTC) Errors Error Reason
01:00:12-01:00:16 2 conflicting_nonce
05:00:04-05:00:11 4 conflicting_nonce
09:00:04-09:00:11 5 conflicting_nonce
13:00:05-13:00:08 4 conflicting_nonce

Total: 17 errors today (up from 8 yesterday, 1 the day before, 0 the three days before that).

All errors are nonce conflicts from concurrent requests

{
  "path": "/inference/openrouter/chat",
  "errorReason": "conflicting_nonce",
  "transaction": "",
  "network": "stacks:1"
}

Paths hit: /inference/openrouter/chat and /storage/paste — both x402-protected endpoints called within the same second.

The trend is worsening

Date Errors
Mar 19-21 0
Mar 22 0
Mar 23 1
Mar 24 8
Mar 25 17

Whatever scheduled job is calling these endpoints appears to be increasing in concurrency or frequency.

Root cause

The relay's nonce pool is designed to handle concurrent requests (atomic assignment via blockConcurrencyWhile), but when multiple settlements arrive in the same second, they each get a nonce, both broadcast, and the node rejects one with ConflictingNonceInMempool because the first transaction hasn't been indexed yet.

The relay returns NONCE_CONFLICT with retryAfter: 30, but the calling code doesn't retry — it logs the error and moves on.

Proposed fix

Option A: Serialize settlements (recommended)

If the scheduled job calls multiple x402 endpoints, serialize them — wait for each settlement to complete before starting the next:

// Instead of:
await Promise.all([
  fetch("/inference/openrouter/chat", { headers: paymentHeaders }),
  fetch("/storage/paste", { headers: paymentHeaders }),
]);

// Do:
await fetch("/inference/openrouter/chat", { headers: paymentHeaders });
await fetch("/storage/paste", { headers: paymentHeaders });

Option B: Add jitter between calls

If parallelism is needed for performance, add 2-5 second random jitter between calls:

for (const endpoint of endpoints) {
  await fetch(endpoint, { headers: paymentHeaders });
  await new Promise(r => setTimeout(r, 2000 + Math.random() * 3000));
}

Option C: Retry on NONCE_CONFLICT

This is already tracked in #84. When the relay returns retryAfter: 30 and code: "NONCE_CONFLICT", wait and retry. This would recover from the conflict without changing the call pattern.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions