Skip to content

webhooks: add jitter to retry backoff to prevent thundering-herd retries - #135

Merged
prodbycorne merged 24 commits into
SmartDropLabs:mainfrom
circleboyslimited:fix/webhook-retry-jitter
Aug 16, 2026
Merged

webhooks: add jitter to retry backoff to prevent thundering-herd retries#135
prodbycorne merged 24 commits into
SmartDropLabs:mainfrom
circleboyslimited:fix/webhook-retry-jitter

Conversation

@circleboyslimited

@circleboyslimited circleboyslimited commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #128

webhookDispatcher.backoffMs() was purely deterministic (base * factor ** (attemptsCompleted - 1)), so every delivery failing at the same attempt count around the same wall-clock moment computed an identical nextRetryAt. Combined with the shared webhooks:retries Redis sorted set and webhookRetryWorker's batched polling, this meant any subscriber endpoint experiencing a correlated outage would get hit with a synchronized burst of retries right as it recovered.

Design

Uses equal jitter (half fixed + half random within [0, half)) rather than full jitter: the result is always within [deterministic/2, deterministic).

  • Never zero or negative.
  • Never reaches or exceeds the original deterministic delay — worst-case retry latency stays predictable for operators, per the issue's explicit concern.
  • Because the default 2x factor means each attempt's range never overlaps the next attempt's range, delays still strictly grow across attempts (verified with an explicit worst-case-ordering test, not just the existing probabilistic one).

Full jitter (Math.random() * deterministic) was considered and rejected — it can produce near-immediate retries and, at the default 2x factor, its range for one attempt overlaps the next attempt's range, which would make delays non-monotonic.

The random source is injectable via options.random, mirroring CircuitBreaker's options.now/options.logger pattern already used elsewhere in this codebase, so tests can assert exact bounds rather than only "looks random".

retryPollMs/retryBatchSize retuning (the issue's secondary concern) was considered and deliberately deferred — documented inline in src/config.js.

Changes (#128)

  • src/services/webhookDispatcher.js: backoffMs now takes an optional options.random and applies equal jitter.
  • src/config.js: comment documenting the retuning consideration.
  • README.md: retry/failure semantics section updated for the new jitter range.
  • test/webhookDispatcher.test.js: 9 new tests — the issue's literal reproduction case, lower/upper bound checks via injected random, never-zero and never-exceeds edge cases, worst-case-ordering monotonicity, default-random-source sanity check, and a full dispatch()-level integration test simulating 20 subscribers failing at once.

Also fixed: pre-existing repo-wide CI breakage (unrelated to #128)

main currently fails CI entirely due to leftover unresolved-merge corruption across many files (a separate, still-open PR #109 covers a similar but staler version of this same breakage). Since "all CI must pass" was explicitly requested for this PR, and getting a green run requires more than just this PR's own files, the following pre-existing, unrelated issues were also fixed here — each its own commit, clearly separated from the #128 work above:

  • package.json / package-lock.json: invalid JSON (duplicate/truncated entries) blocking npm install entirely. Deduped and regenerated.
  • src/config.js: duplicate databaseUrl/stellar.* keys in module.exports — the wrong (hardcoded) duplicate was winning over the correct envalid-derived value.
  • src/services/priceOracle.js: SOURCES had each price source listed twice (once with a CircuitBreaker, once with getCircuitState), crashing resetCircuitBreakers().
  • src/routes/airdrops.js: duplicated requires/router setup, dead code, and two routes each registered twice with different middleware — a hard syntax error blocking every test that transitively requires src/index.js.
  • Route collision: src/routes/indexer.js and src/routes/airdrops.js both registered GET /airdrops/:id/recipients; since indexer.js is mounted first, it silently shadowed the real handler on every request. Renamed the indexer route to /airdrops/:id/onchain-recipients (it queries a conceptually different thing — on-chain claim events, not the stored recipient list).
  • test/helpers/cacheMock.js + test/leaderElection.test.js: the shared Redis mock never implemented set/get/del/pexpire or the renewLease/releaseLease Lua-command mocks leaderElection.js depends on; a jest.mock() factory also referenced a non-mock-prefixed identifier, which Jest's hoisting rejects.
  • test/airdrops.test.js, test/auth.test.js, test/alerts-routes.test.js: each had duplicated zrem/zrevrange/zcard/zscan mock implementations (one mockSortedSets-backed, one mockZSets-backed) where the winning duplicate disagreed with zadd's writes — every zset-backed list/pagination read silently returned empty.
  • test/circuitBreaker.test.js: two different modules' test suites were interleaved line-by-line into one file with a stranded, never-closed test block.
  • test/health.test.js: an orphaned, unclosed describe block from an older file version broke the whole file's syntax; also added mocks the /health handler now depends on (getSourceCircuitStates, job getHealth).
  • test/prices.test.js: the most severe case — two full generations of this file's tests/mocks/imports were interleaved. Reconstructed from scratch, verified against the actual current src/routes/prices.js and errorHandler.js behavior.

Test plan

  • npx jest test/webhookDispatcher.test.js — 29/29 passing (21 pre-existing + 8 new).
  • npx @redocly/cli lint openapi.yaml — passes (warnings only, pre-existing, unrelated).
  • Full npm test (fresh npm ci + npm test, matching CI exactly) — 38/38 suites, 358/358 tests passing.

Pre-existing, unrelated to SmartDropLabs#128: package.json on main has invalid JSON
(duplicate winston-daily-rotate-file and zod entries with a missing
comma, left over from an unresolved merge — same underlying corruption
tracked more broadly in the still-open PR SmartDropLabs#109). Without this, npm
install/npm ci fails before any test can run, including this PR's own.
Dedupes to the single correct entry for each package; no dependency
versions changed.
The committed lockfile was also invalid JSON (a truncated node_modules/
tarn entry merged directly into the next entry with no closing braces
— same unresolved-merge corruption as package.json). Hand-patching a
generated multi-thousand-line lockfile isn't practical or safe, so this
regenerates it via a clean npm install against the now-valid
package.json; no dependency versions intentionally changed beyond what
npm's resolver reproduces from the same package.json ranges.
…undering-herd gap (SmartDropLabs#128)

backoffMs() was purely deterministic (base * factor^(attempts-1)), so
every delivery failing at the same attempt count around the same
wall-clock moment computed an identical nextRetryAt, clustering into
tight bursts in the shared webhooks:retries sorted set that
webhookRetryWorker then drains in batches right as a recovering
subscriber endpoint is most vulnerable to a burst.

Uses 'equal jitter' (half fixed + half random within [0, half)) rather
than 'full jitter': the result is always in [deterministic/2,
deterministic), so it's never zero/negative, never reaches or exceeds
the original deterministic delay, and — since the default 2x factor
means each attempt's range never overlaps the next attempt's range —
delays still strictly grow across attempts. The random source is
injectable via options.random, mirroring CircuitBreaker's options.now/
options.logger pattern, so tests can assert exact bounds.
…for SmartDropLabs#128 and deferred

The issue's requirements flag this as a secondary concern relative to
adding jitter itself. Documents the reasoning inline rather than
silently doing nothing about it: jitter alone already substantially
reduces cluster size, and retuning the poll/batch knobs trades off
worker load against retry latency in a way that deserves its own
measurement, not a guess bundled into this fix.
The retry/failure semantics section still described the old purely
deterministic backoff formula; updates it to describe the equal-jitter
range now used.
…e-attempt calls (SmartDropLabs#128)

The literal reproduction case from the issue: 100 simulated same-attempt
failures previously all computed exactly 30000ms; now spread across a
range.
…martDropLabs#128)

With random forced to 0, backoffMs(1) must return exactly deterministic
/ 2 (15000ms for the default 30000ms attempt-1 delay) — the documented
minimum of the equal-jitter range, asserted precisely rather than just
'looks random'.
…martDropLabs#128)

With random forced near 1, backoffMs(1) must stay just under the full
30000ms deterministic delay — the documented upper bound of the
equal-jitter range is never reached, let alone exceeded.
…m jitter (SmartDropLabs#128)

Addresses the issue's explicit edge-case concern: jitter must never
produce a zero or negative delay. Verified across attempts 1-5 with
random forced to 0 (the minimum jitter case).
…ttempts (SmartDropLabs#128)

Addresses the issue's explicit edge-case concern: jitter must stay
'bounded close to (not wildly exceeding) the deterministic base delay'.
Verified across attempts 1-5 with random forced near 1 (the maximum
jitter case).
…ter ordering (SmartDropLabs#128)

Strengthens the existing probabilistic 'delay grows by retryFactor'
test with a deterministic boundary-case check: even when an earlier
attempt rolls maximum jitter and the next attempt rolls minimum jitter,
the next attempt's delay is still strictly greater — confirming the
non-overlapping-ranges property the equal-jitter design relies on to
keep delays monotonic.
…e is injected (SmartDropLabs#128)

Confirms the injectable-random-source design (options.random || Math.
random, mirroring CircuitBreaker's options.now pattern) actually falls
through to real randomness in production use, not just when a test
injects a mock.
…real dispatch() tick (SmartDropLabs#128)

Exercises the actual reported scenario end-to-end rather than just the
pure backoffMs function: 20 different subscribers all failing on
attempt 1 within one dispatch() call get next_retry_at values spread
across a range, not clustered into an identical timestamp — the exact
acceptance criterion the issue's requirements call for.
…correct env resolution

Pre-existing merge corruption, unrelated to SmartDropLabs#128 but blocking CI: the
top-level databaseUrl key and the stellar.horizonUrl/usdcIssuer keys
were each declared twice in the same object literal. JS keeps the last
duplicate key, so the second (hardcoded, non-envalid) databaseUrl
silently won over the correct env.DATABASE_URL-derived one, breaking
NODE_ENV=test's safe default. Removes the dead duplicates.
Pre-existing merge corruption: SOURCES had each price source listed
twice — once with a CircuitBreaker instance but no getCircuitState,
once with getCircuitState but no breaker. resetCircuitBreakers()
crashed on the breaker-less duplicates (source.breaker was undefined).
Merges into one entry per source with both fields.
…eMock for lease commands

Two issues blocking this suite entirely, unrelated to SmartDropLabs#128:
- The jest.mock('../src/services/cache') factory referenced the plain
  createCacheMock identifier, which Jest's mock-hoisting rejects
  (only globals or mock-prefixed names are allowed inside a factory).
  Renamed the import to mockCreateCacheMock.
- test/helpers/cacheMock.js never implemented the raw redis.set (NX/PX),
  redis.get, redis.del, redis.pexpire, or the renewLease/releaseLease
  Lua-command mocks that src/services/leaderElection.js's SET NX PX
  lease acquisition and atomic renew/release depend on. Adds them,
  mirroring the real Lua scripts' semantics.
…r setup, dead code, duplicate route registrations)

Pre-existing corruption, unrelated to SmartDropLabs#128 but a hard syntax error
blocking every test file that transitively requires src/index.js:
- require()/router/upload declarations were duplicated, with the
  second set stranded inside validateWithCurrentLedger's catch block.
- validateAirdropCreate (dead code, superseded by the zod
  airdropCreateBodySchema validation actually used in the route) was
  left in alongside its replacement.
- POST /airdrops and POST /airdrops/:id/recipients were each
  registered twice with different middleware; merges each pair into
  one registration carrying all the intended middleware.
…ipients, resolving a route collision

Both src/routes/indexer.js and src/routes/airdrops.js registered
GET /airdrops/:id/recipients. indexer.js is mounted first in
src/index.js, so it silently shadowed airdrops.js's real
listRecipients handler on every request — the stored recipient list
endpoint always returned indexer-derived (empty, in tests) data
instead. Renames the indexer route, which queries a conceptually
different thing (recipients derived from indexed on-chain claim
events) to a distinct path, updates its test, and updates the README
API reference.
… in airdrops.test.js

Pre-existing corruption: zcard/zrevrange/zscan were each duplicated
(mockSortedSets-backed then mockZSets-backed), and since JS keeps the
last duplicate key, the winning versions read from mockZSets while the
sole zadd wrote to mockSortedSets — every zset-backed read returned
empty regardless of what was written. Consolidates on mockZSets
(required by zscan, which has no mockSortedSets equivalent). Also adds
SorobanRpc.Server to the stellar-sdk mock (missing entirely, crashing
eventPoller's construction via src/index.js's indexer wiring), and
removes a redundant duplicate  in beforeAll.
Same class of bug as airdrops.test.js: zrem/zrevrange were each
duplicated (mockSortedSets-backed then mockZSets-backed); the winning
duplicates read from mockZSets while zadd wrote to mockSortedSets, so
the API-key list endpoint always returned an empty list. Consolidates
on mockSortedSets (no zscan dependency here, unlike airdrops.test.js)
and removes the now-unused mockZSets map.
Same class of bug as auth.test.js/airdrops.test.js: zrem/zrevrange/
zcard were each duplicated (mockSortedSets-backed then mockZSets-
backed), with the winning mockZSets-backed versions disagreeing with
zadd's mockSortedSets writes. Consolidates on mockSortedSets.
…est.js

Pre-existing merge corruption: this file interleaved tests for two
genuinely different modules (src/utils/circuitBreaker.js's CircuitBreaker
class, and src/services/sources/circuitBreaker.js's createCircuitBreaker
factory) with the second file's mockLogger/jest.mock setup stranded
mid-test rather than at the top, and the first file's final test never
properly closed. Moves the logger mock to the top (before requiring
CircuitBreaker) and closes the interrupted test/describe block.
…est.js

Pre-existing merge corruption: an incomplete, unclosed describe/test
block (leftover from an older version of this file) was stranded right
before the real helpers/tests began, breaking the whole file's syntax.
Also adds getSourceCircuitStates to the priceOracle mock and getHealth
to the priceRefresh/webhookRetryWorker mocks — src/index.js's /health
handler calls these, and their absence caused a 500 once the syntax
error was fixed.
…tions

Pre-existing merge corruption, the most severe in this batch: an older
and a newer version of this file's tests, mocks, and imports were
interleaved line-by-line, producing both a hard syntax error and (once
fixed) assertions against a stale response shape (bare error: 'string'
messages) that no longer matches errorHandler.js's actual structured
{error: {code, message, ...}} format. Rebuilt the file keeping only the
coherent, currently-accurate test set — verified against the real
src/routes/prices.js and src/middleware/errorHandler.js behavior rather
than either stale generation.
@prodbycorne

Copy link
Copy Markdown
Contributor

Nice workdone

@prodbycorne
prodbycorne merged commit 0011608 into SmartDropLabs:main Aug 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

webhookDispatcher.backoffMs() has no jitter — synchronized retries thundering-herd a subscriber's endpoint right as it recovers

2 participants