Spraxium logoSpraxium

Delivery and Reliability

Operate Signal Client safely under production traffic with persistent fallback. Configure file or Redis stores, exponential backoff, dead-letter handling, and graceful shutdown.

Reliability model

Signal Client combines webhook rotation with optional fallback persistence so that burst traffic and temporary failures do not become lost bot-side updates. The design is intentionally layered: first try live delivery through the pool, then queue failed envelopes and retry them with exponential backoff. This gives bot teams a practical path to reliability without forcing every producer service to implement its own retry scheduler.

The FallbackWorker is the core reliability primitive. When the fallback system is enabled and a send call fails, the worker persists the envelope into a FallbackStore and retries it in background batches. After exhausting the maximum number of attempts, the envelope is moved to a dead-letter partition for manual inspection.

Fallback configuration reference

The FallbackConfig object controls every aspect of the retry system. All fields except enabled and store have sensible defaults, so a minimal configuration only needs to toggle the feature on and choose a persistence backend.

FieldTypeDefaultPurpose
enabledbooleanrequired

Activates the fallback system. When true, the worker starts on construction and begins processing immediately.

storefile | redisrequired

Persistence backend. File uses a local JSON file, Redis uses shared keys with a spraxium:signal:fallback: prefix.

retryIntervalMsnumber5000

Base interval between retry batch processing cycles. Also used as the base for the exponential backoff formula.

maxAttemptsnumber10

Maximum retry attempts before an envelope is moved to dead-letter. After this threshold, manual intervention is required.

batchSizenumber20

Maximum number of envelopes claimed per processing cycle. Larger batches improve throughput at the cost of burst pressure.

eventsstring[]undefined

Optional event name filter. When set, only events in this list are enqueued on failure. All other events still fail normally.

filePathstring.spraxium-signal-fallback.json

Path to the JSON fallback file. Only used when store is file. The directory is created automatically if it does not exist.

redisUrlstringundefined

Connection URL for Redis. Only referenced for documentation purposes. You must provide a RedisFallbackStore instance as the second constructor argument.

Choosing a fallback store

The FallbackStore interface defines a persistence contract with eight methods: enqueue, claimBatch, markProcessed, reschedule, moveToDeadLetter, has, pendingCount, and deadLetterCount. Two built-in implementations cover the most common deployment scenarios. You can also implement the interface yourself for custom backends.

import { SignalClient } from '@spraxium/signal-client';

const client = new SignalClient({
  webhookUrls: [process.env.SIGNAL_WEBHOOK_URL_1!],
  hmacSecret: process.env.SIGNAL_HMAC_SECRET!,
  rateLimitStrategy: 'wait',
  fallback: {
    enabled: true,
    store: 'file',
    filePath: '.spraxium-signal-fallback.json',
    retryIntervalMs: 5_000,
    maxAttempts: 10,
    batchSize: 20,
    events: ['config.update', 'billing.status.changed'],
  },
});

FileFallbackStore keeps pending, dead-letter, and processed entries in a single JSON file. It loads the file lazily on first access and flushes after every mutation. The processed list is automatically trimmed to the last 5000 entries when it exceeds 10000, preventing unbounded growth. This store works well for local development and small single-instance services.

RedisFallbackStore persists entries as individual Redis keys under the spraxium:signal:fallback:pending: prefix, with dead-letter entries under spraxium:signal:fallback:deadletter: and processed IDs in a set at spraxium:signal:fallback:processed. Redis is preferable for multi-instance emitters because fallback state is shared across replicas without file system coordination.

Exponential backoff and jitter

When a retry attempt fails, the worker reschedules the envelope using an exponential backoff formula with additive jitter. The formula prevents retry storms by spreading attempts over increasingly longer intervals while the jitter component prevents multiple envelopes from retrying at the same instant.

The exact formula is: retryIntervalMs * 2^min(attempt, 8) + random() * retryIntervalMs * 0.3. The exponent is capped at 8 to prevent absurdly long delays on high attempt counts. With the default retryIntervalMs of 5000 and maxAttempts of 10, the maximum backoff before jitter is roughly 1280 seconds (about 21 minutes) at the eighth attempt.

AttemptBase delayJitter rangeEffective range
110 s0 to 1.5 s10 to 11.5 s
220 s0 to 1.5 s20 to 21.5 s
340 s0 to 1.5 s40 to 41.5 s
5160 s0 to 1.5 s160 to 161.5 s
8+1280 s0 to 1.5 s1280 to 1281.5 s (cap)

Dead-letter handling

When an envelope exhausts the configured maxAttempts, the worker moves it to the dead-letter partition via moveToDeadLetter. The original entry is removed from the pending queue and stored with the last error reason attached. Dead-letter envelopes are never retried automatically, so they require manual investigation.

In the file store, dead-letter entries accumulate in the deadLetter array of the JSON file. In the Redis store, they move to individual keys under spraxium:signal:fallback:deadletter:. Both stores track the count via deadLetterCount, which you should monitor as part of your operational health checks.

The stats method on the FallbackWorker returns both pending and deadLetter counts in a single call. Exposing this data to your monitoring system is the simplest way to detect persistent delivery failures before they affect user-facing bot behavior.

fallback-monitoring.ts
const { pending, deadLetter } = await client.fallback!.stats();

if (deadLetter > 0) {
  logger.warn(
    `Signal fallback: ${deadLetter} dead-letter envelopes require investigation`,
  );
}

if (pending > 100) {
  logger.warn(
    `Signal fallback: ${pending} pending envelopes, possible sustained delivery failure`,
  );
}

Fallback entry lifecycle

Envelope enqueue on failure

When a send call throws and fallback is enabled, the worker checks if the event matches the events filter (or if no filter is set, all events qualify). It then deduplicates by nonce to prevent double-enqueue and creates a FallbackEntry with the envelope, event name, guild ID, zero attempts, and a nextAttemptAt set to now plus retryIntervalMs.

Batch claiming and retry

On each processing cycle (every retryIntervalMs), the worker calls claimBatch to pull up to batchSize entries whose nextAttemptAt has passed. For each entry, it calls send on the client with skipFallback: true to prevent re-enqueue loops. If the send succeeds, the entry is marked as processed.

Exponential reschedule on failure

When a retry attempt fails, the worker computes the next attempt time using the exponential backoff formula with jitter. The entry's attempt count is incremented, the error reason is stored in lastError, and the entry is rescheduled via reschedule. This continues until either delivery succeeds or the attempt limit is reached.

Dead-letter on exhaustion

When the attempt count reaches maxAttempts, the worker calls moveToDeadLetter with the final error reason. The entry is permanently removed from the pending queue and placed in the dead-letter partition. From this point, only manual intervention or a custom script can re-process it.

Reliability profiles by scenario

ScenarioRecommended profileTrade-off
Low-volume events with manual recovery toleranceskip strategy, fallback disabled

Lowest complexity. Producer must own retry logic or accept occasional loss.

Moderate bursts where temporary throttle is expected

'wait' strategy, multi-webhook pool, fallback disabled

Absorbs short rate-limit spikes automatically. Slightly higher latency under pressure.

Business-critical events with low loss tolerancewait strategy, fallback enabled with file store

Persistent retry with dead-letter monitoring. Good for single-instance deployments.

Multi-instance services with shared state requirements

'wait' strategy, fallback enabled with Redis store

Shared fallback state across replicas. Requires Redis infrastructure.

High-throughput mixed events with selective reliability

Fallback enabled with events filter for critical names only

Focuses retry resources on important events. Non-critical events fail fast without queue overhead.

Graceful shutdown

Call destroy on the client during your application shutdown sequence to stop the FallbackWorker cleanly. This clears the internal setInterval timer and prevents partial retry loops during rolling deployments. The worker uses timer.unref() to avoid keeping the Node.js process alive, but explicitly destroying the client ensures deterministic cleanup.

graceful-shutdown.ts
process.on('SIGTERM', () => {
  client.destroy();
  process.exit(0);
});

If you skip the destroy call, pending retry cycles may execute partially during shutdown, potentially producing duplicate deliveries. In containerized environments where shutdowns happen frequently, this is especially important to handle correctly.

Operational runbook

Observe pool behavior continuously

Track pool status via client.pool.status() to understand which webhook endpoints are healthy, throttled, or unavailable. Correlate rate-limit patterns with Discord-side webhook load to determine whether you need to add more webhooks or reduce send frequency.

Monitor fallback queue growth

When fallback is enabled, rising pending counts are your early warning for sustained transport failures. Expose the stats method output to your monitoring system and set alerts for thresholds appropriate to your event volume. A pending count that stays above zero for more than a few minutes usually indicates a systemic issue.

Investigate dead-letter accumulation

Dead-letter growth should trigger immediate investigation. Check the lastError stored on each dead-letter entry to determine whether failures are transient (rate limits, network errors) or permanent (invalid webhook URL, changed secret). For the file store, inspect the deadLetter array in the JSON file directly. For Redis, scan keys under spraxium:signal:fallback:deadletter:*.

Tune retry windows by criticality

High-impact events like billing or moderation escalation justify longer maxAttempts and lower retryIntervalMs for faster initial retries. Low-impact events should use lighter settings to prevent queue pressure from drowning out critical traffic. Use the events filter to apply fallback only to the event names that warrant it.

Destroy emitters during shutdown

Always call client.destroy() in your SIGTERM or graceful shutdown handler. This stops the fallback worker timer and prevents duplicate processing during rolling deployments. In serverless environments where the process may be frozen rather than terminated, consider flushing pending state before the handler returns.