Skip to content
Back to skills

Retry Deadline Scan

ASecurity

Retries and deadlines: eligibility, caps, backoff, nested layers, non-idempotent retries, propagation — read-only.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
ai-agentspythonrustgojavaphpc#shellbashnodespring

Works with

  • cli
  • api

Security analysis

A93/100
  • highPerforms destructive filesystem operations

Pro shows the line behind each finding and how to fix it

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill retry-deadline-scan --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Retry Deadline Scan?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Retry Deadline Scan
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-retry-deadline-scan/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-retry-deadline-scan)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: retry-deadline-scan
description: "Retries and deadlines: eligibility, caps, backoff, nested layers, non-idempotent retries, propagation — read-only."
kind: skill
pack: distributed-systems
allowed-tools: [Read, Grep, Glob, Bash]
---

# Skill: retry-deadline-scan

## Premise

A retry is a second copy of a request the caller could not see finish. Whether that copy is harmless is decided by defaults nobody wrote down: axios-retry retries **any** method — POST included — on a network error, go-retryablehttp and the .NET standard resilience handler retry POST, a bare tenacity `@retry` never stops and never waits, p-retry and Polly never jitter, the AWS SDK already makes 3 attempts before your wrapper makes its own, and a Temporal activity retries forever. Each layer looks reasonable on its own; three layers at 3 attempts are 27 calls on a dependency that is already down (243× at five layers, AWS; 64× at three layers of four, Google SRE). A deadline that is not carried across a hop lets the work run on after the caller gave up, and a retry then runs beside it. This skill finds retries and deadlines in code and config, classifies each as IF (what is retried), WHEN (cap, delay, jitter), HOW (what a retry repeats) and the budget (layers, deadlines), and proves each with its oracle. Read-only: it never edits code and never calls a real dependency.

It owns `/ds-audit`'s Retries (§1–§6) and Deadlines (§7–§8) ledger rows. `@resilience-reviewer` (this pack) judges its rows.

**Every finding cites the retry or deadline `<file:line>` + the call chain it sits on (the layer worksheet below) + the [`broker-semantics.md`](../../../../snippets/broker-semantics.md) row its verdict rests on (RY1–RY12, BQ2, CY3, KA7, WK6, LO2), read at the installed version + the oracle that showed it, or the test that would settle it + the fix in the project's own primitive** (its outbound kernel, retry constant, base producer or deadline helper). A grep hit is a candidate. "Retries look fine" is never printed; the output is the class-coverage block (§ Output). Verification follows [`refute-before-report.md`](../../../../snippets/refute-before-report.md) with [`precedents/distributed.md`](../../../../snippets/precedents/distributed.md) § 14–18 and § 36. The oracles are [`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Distributed, V304–V309.

## The rule

A candidate is a finding when all four hold — the protocol's gates as `precedents/distributed.md` § Reading the seven gates applies them to retries.

1. **Part A holds: this is a retry (or a deadline) on a production path.** Retry re-executes the same failed operation. Polling, a spin-lock, a periodic job and a reconnect loop are not retry (precedent 16; in the WASABI study 29 of 60 false positives were exactly this). A process type the deploy starts runs it (gate 1).
2. **Part B holds at the installed version.** The library version is read from the lockfile or manifest (F1, gate 3), and the default the verdict rests on is a named row: "no jitter" on a library that jitters by default is a fabrication (precedent 15). The AWS SDK row needs the SDK version **and** the environment: the 2026 retry behaviour is opt-in through `AWS_NEW_RETRIES_2026=true` (RY10; F1b).
3. **Nothing already controls it**, across files. A shared outbound kernel that classifies errors, a `retryCondition` that excludes POST, a server that stores the client's idempotency key (precedent 17), a deadline that aborts the work (precedent 36), an inner layer that swallows its own exhausted failure so the outer layer never retries it. **Read the callee bodies in other files** before accepting "no backoff" or "no cap" — WASABI's second false-positive class was a sleep in a helper in another file.
4. **The oracle fired, or the fact is two-ended.** A unit or container oracle (tier C) counts the attempts, the gaps, the downstream calls or the provider effects; or the static fact cites both ends — attempts × per-attempt timeout + backoff against the outer budget, each term with its `file:line` (tier S). Without one the row is `[report-flagged]` / Unconfirmed with the settling test written out.

## Boundaries

One class, one owner. A neighbour that sees the same `file:line` lists it as corroboration.
- **The timeout value and its latency** — whether an outbound call has a timeout at all, whether it is total (body included), whether it fits the dependency's p99, Go `context.Background()` in a handler — are performance `request-path-scan` § 12. **This skill owns retry layering and deadline propagation**: how many layers retry one call (§4), whether the budget is carried across a hop (§7) and whether attempts × per-attempt timeout + backoff fits the caller's remaining budget (§8). A synchronous chain with no retry layer and no async hop stays § 12's.
- **Proxy and load-balancer retries** (`proxy_next_upstream … non_idempotent`, `retryNonIdempotentMethod`, an Envoy `retry_policy` with no method gate) → infrastructure `edge-reliability-scan` § 3 (V284); the platform timeout chain (LB → proxy → app) → § 1 (V282). §5 and §8 here exclude proxy config.
- **Serverless retries** — Lambda async-invoke retries, event-source `maximum_retry_attempts`, SDK × async × redelivery on a function — → infrastructure `serverless-reliability-scan` § 6 (V259), when that pack is installed. This skill keeps chains that run in long-lived processes and prints `→ infrastructure serverless-reliability-scan §6 (k)` for the rest.
- **Money, stock and credit** — a retried charge, a PSP key minted per attempt — → security `logic-race-scan` § 4, when the security pack is installed; §5 hands the row over with the retry mechanism as evidence. Without it, reported here tagged `money retry (owner pack not installed)`.
- **What happens to a message that keeps failing** (poison, DLQ, requeue loops) → `messaging-scan` (this pack) § 3; a job that repeats its effect on **redelivery** → `messaging-scan` § 2. §4 here owns "a job retried by both `attempts` and an inner client"; `messaging-scan` § 3 corroborates when `attempts` has no backoff.
- **Temporal activity options** (`proxyActivities`, `ActivityOptions`, unlimited attempts by default, WK6) → `workflow-scan` (this pack) § 3 owns the row; here the activity is one layer of the §4 worksheet.
- **Lock acquisition retries** (redlock `retryCount`, LO2) are §2 here; the lock itself is `coordination-scan` (this pack).
- **Backend `api-consistency-audit` #13** hands retry eligibility here (§5) and keeps only the policy drift; **job contracts** (overlap, payload drift, result discarded) stay backend's. A client device's offline queue replaying with a fresh key per send → mobile `offline-sync-scan` § 2.
- **Bulkheads and admission control** → performance `request-path-scan` § 14; retry storms at the platform edge → infrastructure as above.

## Adapt to the codebase — the retry lanes

Read every retry, HTTP and SDK library **with its version** (F1). Facts are rows of `broker-semantics.md` § Retry and deadline libraries (tagged and dated there); a row tagged `[K?]` makes its verdict Unconfirmed until the settling test runs.

| Lane | Defaults that decide verdicts | Shapes to grep (pre-flight) |
|---|---|---|
| **Node** — axios-retry, p-retry, got, ky, cockatiel, a project outbound kernel (`retry: {…}` options), `@aws-sdk/*` | RY3 (delay 0; POST retried on a network error), RY2 (no jitter, 10 retries), RY4 (got: 2, no POST, ±100 ms), RY10 (AWS 3 attempts, full jitter, `AWS_NEW_RETRIES_2026`), RY11 (gRPC: no deadline, Node propagation manual) | 0a, 0b, 3b, 4a, 4d, 5a, 5b, 5e, 7a, 7c |
| **BullMQ / Bull** job retries (the outer layer) | BQ2 (`attempts` 1, no `backoff` → immediate retry; a stalled job re-runs once) | F2, 0c, 4d |
| **Python** — tenacity, backoff, stamina, urllib3 `Retry`, Celery `autoretry_for` / `self.retry` | RY1 (bare `@retry`: forever, no wait), CY3 (Celery `max_retries` 3, `None` = forever, `retry_backoff` off), urllib3 does not retry POST by default (`precedents/backend.md` B-R1) | 1a, 1b, 2a, 3b, 5f |
| **.NET** — Polly v8, `Microsoft.Extensions.Http.Resilience`, MassTransit `UseMessageRetry` | RY6 (constant, no jitter, every exception), RY7 (standard handler retries POST; one handler only; `ConfigureHttpClientDefaults` applies it everywhere) | 1c, 3a, 4b, 4c, 5d |
| **JVM** — Resilience4j, Spring 7 `@Retryable`, Spring Retry, Spring Kafka error handlers, `@RetryableTopic` | RY8 (3 attempts, constant 500 ms, every exception), RY9 (`@Retryable` any exception; a second layer), KA7 (`DefaultErrorHandler()` 10 attempts, 0 delay) | 1a, 1c, 3a, 3b, 4c |
| **Go** — go-retryablehttp, cenkalti/backoff, gRPC | RY5 (unjittered, retries POST), RY12 (jittered), RY11 (gRPC propagation automatic in Go) | 3b, 5c |
| **Temporal, Sidekiq, Laravel** (outer layers) | WK6 (activities unlimited), RB1 (Sidekiq 25 retries), LV2 (`$tries = 0` unlimited, [K?]) | 0c, F2; Temporal config → `workflow-scan` §3 |

## Read the code — per call chain, before any §

Start from the retry-policy inventory (0a, 0b) and the outer layers (F2, 0c), not from vocabulary: on the real repo, retry words hit 809 lines in 276 files while the inventory and the layer join named 5 chains. For each chain, answer these; the § each answer feeds is in brackets.
- **Is it retry at all?** The same failed operation re-executed, or a poll, a periodic sweep, a reconnect, a spin on a lock? [part A of every row]
- **What does it retry?** Every exception, or the transient ones (timeouts, connection resets, 5xx, 429, 503)? A validation error, a 4xx, a deserialisation failure, an auth failure retried is wasted attempts and a delayed dead letter [§1; `THR-RES-RETRY.never_on`]
- **When does it stop, and how long between attempts?** A cap, a total time budget, a capped exponential with jitter, `Retry-After` honoured on 429 [§2, §3]
- **How many layers retry this one call?** The SDK, the kernel, the wrapper, the job's `attempts`, the broker's redelivery, an activity policy — and **does the inner layer's exhausted failure reach the outer layer?** A client that catches and returns `null` stops the multiplication [§4]
- **Is the call safe to repeat?** A read, a PUT by key, a write the far end dedupes on a key we send (stable across attempts and across job re-runs) — or a POST that creates [§5]
- **What did attempt 1 leave behind?** A flushed row, a consumed stream, a half-written file, a first external effect the retry repeats [§6]
- **Whose deadline is it?** Does the caller's remaining budget travel with the call (a context, an `AbortSignal`, `grpc-timeout`, an `expiresAt` on the message), and does the work stop when it expires? [§7, §8]

## The layer worksheet (§4, §8)

One worksheet per chain; 4d prints the candidate chains. Fill it from F2 (outer attempts and backoff, with their source), 0b (inner policies) and the reads above:

```
chain: <entry: job/consumer/route>  →  <hop>  →  <call site>
layer   file:line                         attempts  backoff/jitter      per-attempt timeout  propagates?            source row
outer   queue.producer.ts:203 (base)      3         jittered, 1–… s     job deadline 30 s    —                      BQ2 + F2
inner   orders.client.ts:92               3         full jitter ≤ 5 s   15 s (STANDARD tier)  thrown → job fails     0b
SDK     —                                 1         —                   —                    —                      RY10 n/a (no SDK)
product: 3 × 3 = 9 calls per failing action (THR-RES-RETRY.max_attempts 5; precedent 14: one retrying layer)
budget:  15 s × 3 + 2 waits ≤ 5 s = 55 s per inner call vs the 30 s job deadline → §8 candidate (aborted by the job signal? P36)
```

A layer counts only when its exhausted failure **propagates** to the layer above: a client that catches and returns a result, a `null` or a logged warning ends the chain there, and the product is that layer's attempts alone. The product > `THR-RES-RETRY.max_attempts` (5; 3 for money-moving and LLM calls), or more than one retrying layer on a dependency other callers share, is a candidate. `THR-RES-RETRY.layers` is 1 and its `budget` caps the product across layers at `max_attempts` (precedent 14).

## Pre-flight greps

Run as **one** script from the repo root (shell state does not survive between tool calls; bash 3.2 has no `mapfile`). Grep **N** feeds detector **§N**; the F lines are the gates and the outer-layer inventory; 0a–0c are the retry inventory every § starts from. Every `rg` names its path — `.` or `"${ROOTS[@]}"`: given no path, `rg` reads stdin under an agent's shell and reports a silent zero. Include globs come **first** in every command, exclusions last (for a path several globs match, the last one wins). Any `rg` exit 2 is a failed grep, never zero hits. File-level greps use `--files-without-match`, never `rg -L` (that is `--follow`). 4d joins by constructor-injection types (TS, Java, C#); Python and Go chains are a read step from 0c.

```bash
export LC_ALL=C; ROOTS=(.)   # the server-side code: in a monorepo name the apps (ROOTS=(apps/api apps/worker services)); config greps always search .
CODE=(-g '*.{ts,mts,cts,js,mjs,cjs,py,go,java,kt,cs,rb,php}')
X=(-g '!**/node_modules/**' -g '!**/vendor/**' -g '!**/{dist,build,out,bin,obj,target,coverage,.next,.nuxt,.output,generated,__generated__}/**'
   -g '!**/{test,tests,__tests__,spec,specs,e2e,fixtures,__fixtures__,mocks,__mocks__,testdata}/**'
   -g '!**/*.{spec,test,e2e-spec,fuzz,stories}.*' -g '!**/*_test.go' -g '!**/test_*.py' -g '!**/*Tests.cs' -g '!**/*Test.java' -g '!**/*.d.ts' -g '!**/*.min.*'
   -g '!**/{migrations,migration,seeders,seeds}/**' -g '!**/{locales,locale,i18n,translations}/**' -g '!**/{docs,ai,.claude}/**')
MAN=(-g '**/{package.json,requirements*.txt,pyproject.toml,Pipfile,go.mod,pom.xml,build.gradle,build.gradle.kts,*.csproj,Directory.Packages.props,Gemfile,composer.json}' -g '!**/node_modules/**' -g '!**/vendor/**')
NC=(rg -v --pcre2 '^[^:]+:\d+:\s*(?://|/\*|\*(?:\s|/|$)|#)')
CONS='@Processor\(|\bextends\s+\w*WorkerHost\b|\bnew\s+Worker\(|\beachMessage\b|\beachBatch\b|@(?:EventPattern|MessagePattern|RabbitSubscribe|SqsMessageHandler)\(|@(?:shared_task|app\.task|celery\.task)\b|@(?:KafkaListener|RabbitListener|SqsListener|JmsListener)\b|\bIConsumer<|\bsidekiq_options\b|\bimplements\s+ShouldQueue\b|\bworkflow\.ExecuteActivity\(|\bproxyActivities\b'
RLIB='\baxiosRetry\(|\bpRetry\(|\bfrom\s+["\x27](?:async-retry|cockatiel|p-retry|exponential-backoff)["\x27]|@retry\b|\b(?:Async)?Retrying\(|\bstamina\.retry\b|\bbackoff\.on_exception\b|\bHTTPAdapter\(\s*max_retries\b|\bRetryStrategyOptions\b|\.AddRetry\(|\bAdd(?:Standard)?ResilienceHandler\(|\.AddPolicyHandler\(|\.AddTransientHttpErrorPolicy\(|\bWaitAndRetry\w*\(|\bRetryForever\w*\(|@Retryable\b|@RetryableTopic\b|\bRetryTemplate\b|\bRetry\.of\(|\bRetryConfig\.|@Retry\(|\bretryablehttp\.NewClient\(|\bbackoff\.Retry(?:Notify)?\(|\bretry\.Do\(|\bUseMessageRetry\(|\bautoretry_for\b|\bself\.retry\(|\bretry_on\b|\bRetriable\.retriable\b'
ROPT='\bretry\??\s*:\s*(?:\{|[A-Z][A-Z0-9_]{2,}\b)|\b(?:maxRetries|max_retries|maxAttempts|max_attempts|MaxRetryAttempts|RetryMax|retryCount|retry_count|maximumAttempts)\s*[:=]\s*[\w.$-]+'
# F1 retry, HTTP and SDK libraries with their versions (gate 3: RY1-RY12 are read at these); F1b the AWS retry environment (RY10: AWS_NEW_RETRIES_2026 is opt-in); F1L the locked versions (pnpm, npm, yarn) — the lockfile wins over the range
rg -n --no-heading --pcre2 '["\x27]?(?:axios|axios-retry|p-retry|async-retry|exponential-backoff|got|ky|undici|cockatiel|opossum|@nestjs/axios|@grpc/grpc-js|@temporalio/\w+|bullmq|@aws-sdk/[\w-]+|tenacity|backoff|stamina|urllib3|requests|httpx|celery|Polly|Polly\.Core|Microsoft\.Extensions\.Http\.Resilience|Microsoft\.Extensions\.Http\.Polly|resilience4j[\w-]*|spring-retry|spring-kafka|spring-core|hashicorp/go-retryablehttp|cenkalti/backoff\S*|avast/retry-go\S*|google\.golang\.org/grpc|faraday-retry|retriable)["\x27]?(?:\s*[:=<>~^!]=?\s*|\s+v|\s*\(|"\s+Version=")["\x27]?v?\d' "${MAN[@]}" .
rg -n --no-heading -U --pcre2 '^[ \t]*["\x27]?/?(?:axios|axios-retry|p-retry|async-retry|exponential-backoff|got|ky|undici|cockatiel|opossum|@nestjs/axios|@grpc/grpc-js|@temporalio/[\w-]+|bullmq|@aws-sdk/client-[\w-]+)@\d[\w.-]*|"node_modules/(?:axios|axios-retry|p-retry|async-retry|exponential-backoff|got|ky|undici|cockatiel|opossum|@nestjs/axios|@grpc/grpc-js|@temporalio/[\w-]+|bullmq|@aws-sdk/client-[\w-]+)":\s*\{\s*\n\s*"version":\s*"[^"]+"|^"?(?:axios|axios-retry|p-retry|async-retry|exponential-backoff|got|ky|undici|cockatiel|opossum|@nestjs/axios|@grpc/grpc-js|@temporalio/[\w-]+|bullmq|@aws-sdk/client-[\w-]+)@[^\n]*:\s*\n\s+version:?\s+"?[\d.]+' -g '**/{pnpm-lock.yaml,package-lock.json,yarn.lock}' -g '!**/node_modules/**' .
rg -n --no-heading --pcre2 '\bAWS_(?:NEW_RETRIES_2026|MAX_ATTEMPTS|RETRY_MODE)\b|\bretry_mode\s*=|\bmax_attempts\s*=' --hidden -g '*.{env,yml,yaml,json,tf,toml,ini,cfg}' -g '**/.env*' -g '**/Dockerfile*' -g '**/config' -g '!.git/**' -g '!**/node_modules/**' -g '!**/package-lock.json' .
# F2 the outer layers: every job, consumer and task with its resolved attempts and backoff (messaging-scan's ds-inventory.py); F2c is its fallback
DSI=.claude/skills/messaging-scan/scripts/ds-inventory.py; if command -v python3 >/dev/null 2>&1 && [ -f "$DSI" ]; then python3 -B "$DSI" .; else echo 'F2 NOT-RUN (python3 or ds-inventory.py absent): read 0c and every producer for attempts/backoff'; fi
# 0a retry constructs by library; 0b the retry-policy inventory — option literals and named policies (the inner layers); 0c consumer and job files (the outer layers)
rg -n --no-heading --pcre2 "$RLIB" "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --no-heading --pcre2 "$ROPT|\b(?:[A-Z][A-Z0-9_]*_)?RETR(?:Y|IES)(?:_[A-Z0-9_]+)?\s*(?::\s*\w+\s*)?=\s*\{" "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -l --pcre2 "$CONS" "${CODE[@]}" "${X[@]}" "${ROOTS[@]}"
# 1a a bare or unclassified tenacity/Spring retry; 1b a catch-all retry condition; 1c policy files with no exception filter; 1d consumer files that never classify an error (input, not a verdict)
rg -n --no-heading --pcre2 '^\s*@retry\b(?![^\n]*\bretry\s*=)|@Retryable\b(?![^\n]*\b(?:includes|include|retryFor|value|noRetryFor|excludes|exclude)\b)' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --no-heading --pcre2 '\bautoretry_for\s*=\s*\(\s*(?:Exception|BaseException)\s*,?\s*\)|\bretry_if_exception_type\(\s*(?:Exception|BaseException)?\s*\)|\.Handle<Exception>\(\)|\bhandleAll\b|\bretryCondition\s*:\s*\(\s*\w*\s*\)\s*=>\s*true\b|\bretryExceptions\(\s*(?:Exception|Throwable)\.class' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -l0 --pcre2 '\bRetryStrategyOptions\b|\.AddRetry\(|\bRetryConfig\.|\bRetry\.of\(' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg --files-without-match --pcre2 '\bShouldHandle\b|\bretryExceptions\b|\bretryOnException\b|\bretryOnResult\b|\bignoreExceptions\b'
rg -l0 --pcre2 "$CONS" "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg --files-without-match --pcre2 '\bUnrecoverableError\b|\b(?:is)?[rR]etryable\b|\bisTransient\b|\bNonRetryable\w*|\bDoNotRetry\w*|\bdont_autoretry_for\b|\bnot_retryable\b|\bnoRetryFor\b'
# 2a no cap: forever, Infinity, None, -1, Temporal maximumAttempts 0, Forever helpers, a bare tenacity decorator; 2b files with an endless loop AND retry words (read: is it retry, polling or a reconnect? precedent 16)
rg -n --no-heading --pcre2 '\bforever\s*:\s*true\b|\bretries\s*:\s*(?:Infinity|Number\.POSITIVE_INFINITY)\b|\bmax_retries\s*=\s*None\b|\bretryCount\s*:\s*-1\b|\bmaximumAttempts\s*:\s*0\b|\b(?:maxRetries|maxAttempts|max_attempts|MaxRetryAttempts|RetryMax)\s*[:=]\s*(?:-1\b|int\.MaxValue|Integer\.MAX_VALUE|math\.MaxInt)|\b(?:WaitAndRetry|Retry)Forever\w*\(|\$tries\s*=\s*0\b|^\s*@retry\s*(?:\(\s*\))?\s*(?:#.*)?$' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -l0 --pcre2 '\bwhile\s*\(\s*(?:true|1)\s*\)|\bfor\s*\(\s*;\s*;\s*\)|^\s*while\s+True\s*:|^\s*for\s*\{\s*$|\bloop\s+do\b' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg -l -i --pcre2 '\bretr(?:y|ies|ying)\b|\bre-?attempt\w*|\battempts?\b'
# 3a explicit zero, constant or unjittered delays; 3b a library whose default has no delay or no jitter, used without the option that adds it (RY2, RY3, RY5, RY8, CY3); 3c a retry that handles 429 and never reads Retry-After
rg -n --no-heading --pcre2 '\bBackoffType\s*=\s*DelayBackoffType\.Constant\b|\bUseJitter\s*=\s*false\b|\brandomize\s*:\s*false\b|\bnew\s+DefaultErrorHandler\(\s*\)|\bnew\s+FixedBackOff\(|\.Immediate\(\s*\d+\s*\)|\bretryDelay\s*:\s*\(\s*\)\s*=>\s*\d+|\bretry_backoff\s*=\s*False\b|\bwait_fixed\(|\bwaitDuration\(' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
while IFS= read -r p; do rg -l0 --pcre2 "${p%%~~*}" "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg --files-without-match --pcre2 "${p#*~~}" | sed "s/$/  (3b)/"; done <<'EOF'
\baxiosRetry\(~~\bretryDelay\b|\bexponentialDelay\b
\bpRetry\(~~\brandomize\s*:\s*true\b
\bretryablehttp\.NewClient\(~~\bLinearJitterBackoff\b|\.Backoff\s*=
\bRetryConfig\.|\bRetry\.of\(~~\bofExponentialRandomBackoff\b|\brandomizedWaitFactor\b|\bRandomized\w*
\bautoretry_for\b~~\bretry_backoff\s*=\s*True\b
EOF
rg -l0 --pcre2 "$RLIB" "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg -l0 --pcre2 '\b429\b|\bthrottl\w*|TooManyRequests' | xargs -0 -r rg --files-without-match -i --pcre2 'retry-?after|\bretryAfter\w*|\bRetryAfter\w*'
# 4a an SDK that retries by itself (AWS 3 attempts, RY10; Google/Azure clients) inside a retry construct; 4b .NET handlers stacked on one client or on every client; 4c a retry annotation or policy on a listener whose container also retries
rg -l0 --pcre2 "$RLIB" "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg -H -n --no-heading --pcre2 '@aws-sdk/client-|\baws-sdk\b|\bimport\s+boto3\b|\bboto3\.client\(|software\.amazon\.awssdk|aws-sdk-go|@google-cloud/|\bfrom\s+google\.cloud\b|\bAzure\.\w+' | "${NC[@]}"
rg -n --no-heading --pcre2 '\bConfigureHttpClientDefaults\(|\bAdd(?:Standard)?ResilienceHandler\(|\.AddPolicyHandler\(|\.AddTransientHttpErrorPolicy\(|\bAddStandardHedgingHandler\(' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -l0 --pcre2 '@(?:KafkaListener|RabbitListener|SqsListener|JmsListener)\b|\bUseMessageRetry\(' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg -H -n --no-heading --pcre2 '@Retryable\b|\bRetryTemplate\b|@Retry\(|\bUse(?:Delayed|Scheduled)Redelivery\(|\bUseMessageRetry\(' | "${NC[@]}"
# 4d the layer join (TS/Java/C# constructor injection, two hops): an outer-layer file (0c) that injects, directly or through one class, a class or interface declared in a file holding a retry site (0a/0b) — each line: outer ← hop ← retrying file
T=$(mktemp -d "${TMPDIR:-/tmp}/rds.XXXXXX")
rg -l --pcre2 "$RLIB|$ROPT" "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | sort -u > "$T/l0"
rg -l --pcre2 "$CONS" "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | sort -u > "$T/out"
rg -o -N --no-filename --pcre2 '\bimplements\s+\K[A-Z]\w+' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | sort | uniq -c | awk '$1 >= 3 {print $2}' > "$T/common"   # shared bases (UseCase, OnModuleInit) join everything: dropped
hop() { tr '\n' '\0' < "$1" | xargs -0 -r rg -H -o --no-heading --pcre2 '\b(?:class|interface)\s+\K[A-Z]\w+|\bimplements\s+\K[A-Z]\w+' | awk -F: 'FILENAME == ARGV[1] {c[$1]; next} !($2 in c)' "$T/common" - | sort -u | sort -t: -k2 > "$1.d"
  [ -s "$1.d" ] || return 0; alt=$(cut -d: -f2 "$1.d" | sort -u | paste -sd'|' -)
  rg -H -o --no-heading --pcre2 "(?:readonly|private|protected|public)\s+\w+\s*:\s*\K(?:$alt)\b" "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | sort -u | sort -t: -k2 | join -t: -1 2 -2 2 -o 1.1,0,2.1 - "$1.d" | awk -F: '$1 != $3'; }
hop "$T/l0" > "$T/j1"; cut -d: -f1 "$T/j1" | sort -u > "$T/l1"; hop "$T/l1" > "$T/j2"
awk -F: 'FILENAME==ARGV[1]{o[$1]=1;next} ($1 in o){print $1" ← "$2" ← "$3}' "$T/out" "$T/j1"
awk -F: 'FILENAME==ARGV[1]{o[$1]=1;next} FILENAME==ARGV[2]{h[$1]=h[$1]" "$2"@"$3;next} ($1 in o)&&($3 in h){print $1" ← "$2" ← "$3" ←"h[$3]}' "$T/out" "$T/j1" "$T/j2"
rm -rf "$T"
# 5a a write sent with a retry option in the same call; 5b axios-retry clients (RY3: POST retried on a network error); 5c go-retryablehttp files that POST (RY5); 5d .NET standard handler with no unsafe-method opt-out (RY7); 5e a key minted fresh where it should be stable (read: inside the retried callback or the job body?); 5f urllib3 retries every method
rg -n --no-heading -U --pcre2 '\bmethod\s*:\s*["\x27](?:POST|PATCH|post|patch)["\x27](?:(?!\bmethod\s*:)[^;]){0,600}?\bretry\??\s*:\s*(?!undefined\b|false\b|null\b|0\b)|\bretry\??\s*:\s*\{[^{};]{0,300}?\bmethods\s*:\s*\[[^\]]*["\x27](?:post|POST|patch|PATCH)["\x27]' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | rg --pcre2 '\bmethod\s*:|\bretry\??\s*:\s*\{'
rg -n --no-heading --pcre2 '\baxiosRetry\(' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -l0 --pcre2 '\bretryablehttp\b' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg -H -n --no-heading --pcre2 '\.Post(?:Form)?\(|\bMethodPost\b|"POST"'
rg -l0 --pcre2 '\bAddStandardResilienceHandler\(' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg --files-without-match --pcre2 '\bDisableForUnsafeHttpMethods\(|\bDisableFor\('
rg -l0 -i --pcre2 'idempot\w*|client[-_]?token|dedupe?[-_]?key' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg -H -n --no-heading -i --pcre2 '(?:idempot\w*|\w*key|client_?token|reference|dedupe\w*)["\x27]?\s*[:=,]\s*(?:await\s+)?(?:crypto\.)?(?:randomUUID|uuid(?:v4|\.v4|\.uuid4|\.New(?:String)?)?|v4|nanoid|ulid|uuid4|Guid\.NewGuid|UUID\.randomUUID)\(' | "${NC[@]}"
rg -n --no-heading --pcre2 '\ballowed_methods\s*=\s*(?:None|False|[^\n)]*["\x27]POST)|\bmethod_whitelist\s*=\s*False' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 6a retry sites in files that also open or flush a unit of work (read: is the transaction or session created INSIDE the retried block?); 6b consumer files making two or more awaited external calls (read for a per-step checkpoint); 6c an attempt counter incremented in two places; 6d a stream body under retry
rg -l0 --pcre2 "$RLIB|$ROPT" "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg -H -c --pcre2 '\bem\.(?:flush|persistAndFlush|transactional|begin)\b|\.transaction\(|\bsession\.(?:commit|flush)\(|\bSaveChanges(?:Async)?\(|@Transactional\b|\btransaction\.atomic\b'
rg -l0 --pcre2 "$CONS" "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg -H -c --pcre2 '\bawait\s+this\.\w*(?:[cC]lient|[gG]ateway|[aA]dapter|[mM]ailer|[sS]ender|[nN]otifier|[pP]sp|[pP]rovider|[cC]arrier)\w*\.\w+\(' | awk -F: '$2 >= 2'
rg -H -c --pcre2 '\b\w*[aA]ttempts?\w*\s*(?:\+\+|\+=\s*1\b)' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | awk -F: '$2 >= 2'
rg -l0 --pcre2 "$RLIB|$ROPT" "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg -H -n --no-heading --pcre2 '\bbody\s*:\s*(?:fs\.)?createReadStream\(|\bbody\s*:\s*\w*[sS]tream\b|\bReadable\.from\(' | "${NC[@]}"
# 7a gRPC servers (Node, Python) with no deadline read (RY11); 7b a user-facing command enqueued with no expiry (read the consumer: does it check one?) — `code` and `confirm` are whole tokens (not geocode, confirm-order); 7c a deadline built from Promise.race with nothing aborted (precedent 36); 7d deadline helpers (do they thread a signal into the outbound calls?)
rg -l0 --pcre2 '\bServer(?:Unary|Writable|Readable|Duplex)(?:Call|Stream)\b|\bsendUnaryData\b|@GrpcMethod\(|\bgrpc\.ServicerContext\b|\w+Servicer\)' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg --files-without-match --pcre2 '\bgetDeadline\(|\bdeadline\b|\btime_remaining\(|\bpropagate_flags\b'
rg -n --no-heading -i --pcre2 '\.(?:add|send|enqueue\w*|publish|delay|apply_async|perform_async)\(\s*["\x27`][\w:.-]*(?:otp|verif\w*|reserv\w*|hold|quote|login|(?<=[-_.:"\x27`])code\b|(?-i:(?<=[a-z])Code\b)|(?:\b|(?-i:(?<=[a-z])(?=C)))confirm(?:ation)?[-_.:]?(?:code|email)|checkout|price)[\w:.-]*["\x27`](?![^\n]*(?:expiresAt|expires_at|deadline|validUntil|\bttl\b|\bexpir\w*))' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -l0 --pcre2 '\bPromise\.race\(' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg --files-without-match --pcre2 '\bAbortController\b|\.abort\(|\bsignal\b|\bAbortSignal\b'
rg -n --no-heading --pcre2 '\b(?:with(?:Deadline|Timeout)\w*|runWithDeadline|deadlineFor|currentDeadline\w*)\s*(?:=|\()|\bAbortSignal\.any\(|\bcontext\.With(?:Timeout|Deadline)\(' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 8 the outer budgets the inner attempts must fit (job deadline, lock, request deadline, workflow/activity timeouts, Celery limits, .NET total vs attempt) — the worksheet pairs each with 0b's per-attempt timeout × attempts
rg -n --no-heading --pcre2 '\b(?:JOB_TIMEOUT\w*|jobTimeout\w*|REQUEST_(?:DEADLINE|TIMEOUT)\w*|requestTimeout|lockDuration|startToCloseTimeout|scheduleToCloseTimeout|workflowExecutionTimeout|workflowRunTimeout|soft_time_limit|time_limit|visibility_timeout|TotalRequestTimeout|AttemptTimeout|TimeoutSeconds)\b\s*[:=(]' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
```

Reading the output:
- **F1 is the version gate.** Print each library with its source; a range marks its floor. **F1L** prints the locked version: when it printed one, that is the version, not the range's floor (a package locked at two versions: the importer's entry in `pnpm-lock.yaml`, or `<app>/node_modules/<pkg>` in `package-lock.json`, says which one the app gets). F1b with no `AWS_NEW_RETRIES_2026` means the SDK runs its pre-2026 behaviour; with it, re-read RY10 before judging timing or quota.
- **F2 is the outer layer**: `ds-inventory.py` (shipped with `messaging-scan`) prints every job and consumer with its resolved `attempts`, `backoff` and the source of each (call, `defaultJobOptions`, a producer wrapper, a library default). A BullMQ row with `attempts` > 1 and no `backoff` is a §3 candidate (BQ2: an immediate retry).
- **0b is the inner layer**: every retry option and named policy. Group by policy constant (one `DEFAULT_RETRY` used at 20 sites is one row with 20 sites).
- **4d is the subject list for §4**: `outer ← hop ← retrying file`. Each line is a worksheet to fill, never a verdict — the propagation read decides it.
- The **file-level lines** (1c, 1d, 2b, 3b, 3c, 5d, 7a, 7c) list files: read each whole. 1d is an input (consumer files that never classify an error), never a verdict.

Three blind spots are structural, so work around them instead of trusting a quiet grep:
- **A project kernel owns the loop.** One `for (;;)` in an outbound kernel serves every call; 0a is quiet and 0b holds the opt-ins. Read the kernel once (classification, jitter, `Retry-After`, the signal it races) and cite it on every row.
- **DI hides the hop.** A processor injects a use case, the use case injects a port, the port's implementation retries. 4d follows two hops by type name and drops interfaces with three or more implementers (a shared `UseCase` base joined every file to every client: 411 lines → 5 on the real repo).
- **The SDK retries invisibly.** An AWS, Google or Azure client inside a wrapper is a layer whether or not the code says "retry" (RY10). 4a lists them only where a retry construct sits in the same file; the worksheet asks for every SDK on the chain.

### Measured (2026-10-14)

| Tree | Result |
|---|---|
| Research samples (`samples/` 15 stacks, `samples-B/` 7) | 0.4 s, 0 rg errors. Every retry BAD surfaces in its §: Celery `autoretry_for=(Exception,)` (1b) with `max_retries=None` and `self.retry(…, max_retries=None)` (2a), Laravel `$tries = 0` (2a, LV2), MassTransit `r.Immediate(50)` and Spring `new DefaultErrorHandler()` + `FixedBackOff` (3a), Celery with no `retry_backoff` (3b), `UseMessageRetry` on a receive endpoint (4c), `@RetryableTopic` (0a, after it was added to the construct list). The good Celery task (`retry_backoff=True, retry_jitter=True`) stays out of 3b; the Temporal `proxyActivities` retry with no timeout is printed by 0b for `workflow-scan` §3 |
| Seeded tree (14 files: Node, Python, Go, .NET, Java) | Every seed surfaces: bare `@retry` (1a, 2a), catch-all `autoretry_for` (1b), Polly options with no `ShouldHandle` (1c), `forever: true` (2a), an endless `try/continue` loop that logs "retrying" (2b), `randomize: false`, `UseJitter = false`, constant Polly (3a), axios-retry with no delay, p-retry with no `randomize`, go-retryablehttp default backoff, Celery with no backoff (3b), an S3 call inside p-retry (4a), two .NET handlers (4b), `@Retryable` on a `@KafkaListener` (4c), the job → use case → retrying port chain (4d), a POST with a retry opt-in (5a), `axiosRetry(client)` (5b), retryablehttp POST (5c), the standard handler with no opt-out (5d), a key minted inside the retry callback (5e), urllib3 `allowed_methods=None` (5f), a gRPC handler with no deadline read (7a), an OTP job with no expiry (7b), `Promise.race` with nothing aborted (7c), a 30 s job deadline (8). Decoys stay out: a jittered tenacity retry with `retry=`, a jittered reconnect loop with no retry vocabulary (precedent 16) |
| `benchmarks/fixtures/*` (18) | Quiet: F1 context in 7 fixtures; 0c/1d list 3 consumer files (input); 8 one server `requestTimeout`; 5e 2 lines in mobile-defects — a device offline queue minting `Idempotency-Key: uuid()` per send → mobile `offline-sync-scan` § 2, outside server ROOTS. Narrowed before shipping: 7d from 33 lines over 10 fixtures (`AbortSignal.timeout` on every fetch is § 12's timeout presence) to deadline helpers and `AbortSignal.any` only; 5a's `methods: […]` arm from 29 route and CORS lines to retry objects only; 5e from 5 lock tokens to key-named assignments |
| Real repository (NestJS 11, BullMQ 6.3.4 in the lockfile — F1L prints it since 2026-10-14 with ky, `@grpc/grpc-js`, an S3 client and undici locked at 6 and 8 (12 lines; the importer's entry says which undici the app gets) — a shared outbound kernel; read-only, about 11,100 files, ROOTS = the API app; re-run on the full tree 2026-10-14: 4.2 s with F2, the same 5 chains, 0b 71 lines and 5a 19 lines — 69 and 16 on the earlier partial copy, the new ones unread) | 2.2 s (+ 2.2 s for F2). 0a **0** library constructs: retry lives in one kernel, opt-in per call ("only where the operation is idempotent"), full jitter, error classification, every attempt racing the ambient deadline signal. 0b 69 policy lines; F2 resolves the outer layer: `attempts` 3 from the base producer, 5 on webhook jobs, jittered backoff. **4d: 5 chains** from 8 consumer files, **including the eval's 3 × 3** (a subscription-refresh job at `attempts: 3` → a client listing subscriptions with `retry: { attempts: 3 }`). Its read **refutes** it at gate 4: the client catches the exhausted failure and returns `null`, and the registration POST has no retry, so the job never fails on them — at most 3 calls per list, not 9. The same join surfaced the live candidate: the webhook job at 5 attempts → an order-fetch GET at `attempts: 3` whose failure the processor rethrows → up to 15 GETs per failing delivery. Read on the full tree: the kernel's attempt count is total calls (3), every attempt passes one breaker per dependency (5 failures open it for 30 s; an open breaker fails fast and is not retried), and the job ladder waits 30 s × 4ⁿ — so 15 is the ceiling for intermittent failures, and a provider outage meets the breaker, not 15× load: Hardening, V306 to count it. 5a **12 POST sites** with an in-call retry, each with a written reason (the far end dedupes; a model call; a GraphQL read) — pointers, not evidence (precedent 34); one relies on the caller's list diff, which the in-call retry does not re-run (Unconfirmed, V307). 5e 1 (a key minted once per operation and reused by its follow-up). 7c **0**; 7d the request and job deadline helpers (`AbortSignal.any` into every attempt: the precedent-36 control). §8: `JOB_TIMEOUT_MS` 30 s default vs a 15 s tier × 3 + 2 waits ≤ 5 s = 55 s — inner > outer, cut by the job's abort → Hardening |

## The 3,000-hit path

1. **Intersect** with F3 from `messaging-scan` (process roles): a retry in code no production process starts (a script, a seed, a CLI) is dismissed with one read. Name the directory in the coverage block.
2. **Group by root cause**: one kernel, one retry constant, one `ConfigureHttpClientDefaults`, one base producer is **one** finding with N sites. Twenty opt-ins of `DEFAULT_RETRY` on reads are one row.
3. **Rank**: chains that end in a write (§5), then chains with two or more layers (§4), then shared dependencies with many callers (§3), then the rest.
4. **Read the top 30 groups** with the read-the-code questions; for WASABI's IF outliers, compare one exception's retry ratio across sites (retried at ≥ 2/3 of its sites but not here, or the reverse). Sample 10 sites per group; the rest inherit the verdict with the group's id.
5. **Run the bulk oracle once**: V306 (count downstream calls for one failing action) on the top chain of each group, against a mock server.
6. **Ledger the rest** as `Live, unreviewed (k groups / n hits — <continuing command>)`. Never mark it `checked`.

## Scans for

Severity assumes a production chain reached by F3. A retried write that creates an order, ships a parcel or sends a customer message is **high** for §5; money is security's (§ Boundaries).

| § | Classes | Default severity |
|---|---|---|
| 1 | RT-1 non-retryable error retried (IF; backend BJ-09) | medium; low when attempts ≤ 3 and the only cost is a delayed dead letter |
| 2 | RT-2 no cap (WHEN) | high on a consumer, a job or a shared dependency; medium elsewhere |
| 3 | RT-3 no backoff, no jitter, `Retry-After` ignored (WHEN) | medium; high on a dependency many callers share |
| 4 | RT-4 retries multiplied across layers | high when the product > `THR-RES-RETRY.max_attempts` on a shared dependency; medium otherwise |
| 5 | RT-5 retry of a non-idempotent call (backend BO-02, BO-05) | high; money → security |
| 6 | RT-6 state not reset; a retry repeats an earlier step (HOW; backend BJ-10) | medium; high when the repeated step is an external effect |
| 7 | TD-1 deadline not propagated across a hop | medium; high for a command that is wrong once late (precedent 18) |
| 8 | TD-2 timeout order inverted (inner × attempts ≥ outer) | medium; high when the overrun leaves work running that a retry duplicates (precedent 36) |

### 1. Non-retryable error retried (RT-1)

```
BAD:   @retry(stop=stop_after_attempt(5)) around a call that raises ValidationError (no retry=, RY1)
       new RetryStrategyOptions { MaxRetryAttempts = 3 } with the default ShouldHandle (every exception, RY6)
       @Retryable(maxRetries = 3) with no includes (RY9) · autoretry_for=(Exception,) · retryCondition: () => true
       a BullMQ processor that throws a 400 from the provider like any other error (no UnrecoverableError, no classification)
GOOD:  retry=retry_if_exception_type(TransientError) · ShouldHandle on HttpRequestException + 5xx/408/429 · includes = …
       throw new UnrecoverableError(…) for permanent errors · a kernel whose error type answers retryable (timeout, network, 5xx, 429)
```
**Read the code:** Which errors reach the retry — does the client map 4xx to an error type the policy excludes? Is a permanent failure (validation, 4xx other than 408/429, deserialisation, auth) ever retried, and what does each wasted attempt cost (a provider call, a partial effect, a delayed dead letter)? Libraries that classify by default (got, the .NET standard handler, the AWS SDK, Spring Kafka's not-retryable list, KA7) are not RT-1. **Finding when** a permanent error is retried more than once. **Proof (V304):** inject a permanent error at the call: exactly 1 attempt after the fix (more before).

### 2. No cap (RT-2)

```
BAD:   bare @retry (RY1) · pRetry(fn, { forever: true }) · retries: Infinity · max_retries=None (CY3) · redlock retryCount: -1 (LO2)
       while (true) { try { return await step() } catch { log.warn('retrying'); continue } } · a negative configured cap read as "infinite"
GOOD:  stop_after_attempt(n) / a total-time budget · max_retries=5 · a loop with an attempt bound and a surfaced final error
```
**Read the code:** Is the loop retry or a reconnect, a poll, a sweep (precedent 16 — a jittered reconnect on a long-lived connection is not RT-2)? Where is the bound — the call, a policy constant, a helper in another file? Is a configured cap ever negative or zero with an "unlimited" meaning (Temporal `maximumAttempts: 0`, WK6)? Is the counter incremented twice (6c: half the cap)? **Finding when** a persistently failing call is retried without bound. **Proof (V304):** a persistent transient error: attempts ≤ the stated cap, then the error is surfaced or dead-lettered.

### 3. No backoff, no jitter, `Retry-After` ignored (RT-3)

```
BAD:   axiosRetry(client) with no retryDelay (delay 0, RY3) · p-retry default (randomize false, RY2) · retryablehttp.NewClient() (RY5)
       new DefaultErrorHandler() (10 attempts, 0 delay, KA7) · Polly BackoffType.Constant / UseJitter = false (RY6) · MassTransit r.Immediate(n)
       BullMQ attempts: 5 with no backoff (BQ2) · a retry that handles 429 and never reads Retry-After
GOOD:  exponential with full jitter and a cap (`random(0, min(cap, base·2ⁿ))`) · retryDelay: axiosRetry.exponentialDelay · LinearJitterBackoff
       `THR-RES-RETRY`: backoff exponential, jitter required, honor_retry_after true
```
**Read the code:** Does the installed library already jitter (got noise, the .NET standard handler, the AWS SDK, cenkalti, Celery `retry_jitter` with `retry_backoff` — precedent 15)? Is the delay computed in a helper in another file? Many callers × one schedule is a synchronised herd even when every site agrees (backend `api-consistency-audit` #13 says the same). **Finding when** a retry fires with no delay, a constant delay, or an unjittered schedule on a dependency several callers share. **Proof (V305):** under a fake clock, record attempt times: the gaps grow, are capped, and two clients' schedules differ.

### 4. Retries multiplied across layers (RT-4)

```
BAD:   a job at attempts: 3 → a use case → a client with retry: { attempts: 3 } whose failure propagates   (9 calls per failing job)
       pRetry(() => s3.send(cmd)) (the SDK already makes 3 attempts, RY10) · ConfigureHttpClientDefaults(…AddStandardResilienceHandler())
       + a per-client handler (RY7) · @Retryable on a @KafkaListener whose container retries (KA7) · UseMessageRetry × UseDelayedRedelivery
GOOD:  exactly one retrying layer per chain: the inner call at attempts 1 (or catching its own exhausted failure), the outer layer owns retry
       · SDK maxAttempts: 1 under one outer policy · one resilience handler per client
```
**Read the code:** Fill the worksheet (§ The layer worksheet) for every 4d line and every SDK on a chain. Does the inner layer's exhausted failure reach the outer layer, or is it caught and turned into a result (the real-repo refutation)? Is the dependency shared (other callers, a provider's rate limit)? An activity's unlimited default (WK6) multiplies everything under it. **Finding when** two or more layers retry one call and the product exceeds the budget. **Proof (V306):** one failing user action against a mock that always fails: count downstream calls; after the fix ≤ the stated budget, with exactly one layer retrying.

### 5. Retry of a non-idempotent call (RT-5)

```
BAD:   axiosRetry(client) on a client that POSTs (RY3: any method on a network error — backend lab: 4 hits for one POST)
       retryablehttp client.Post(…) (RY5) · AddStandardResilienceHandler() with no DisableForUnsafeHttpMethods() (RY7)
       { method: 'POST' }, { retry: DEFAULT_RETRY } on a create · urllib3 Retry(allowed_methods=None)
       pRetry(async () => post(body, { 'Idempotency-Key': randomUUID() }))   // a new key per attempt (BO-05)
       a job retried around a provider call whose key is minted inside the job body (a new key per job attempt)
GOOD:  no retry on the create, or a key from the business id sent on every attempt and every job re-run, verified to be stored by the
       server (precedent 17) · retryCondition excluding non-idempotent methods · DisableForUnsafeHttpMethods()
```
**Read the code:** Does the call create, send or charge? Does the far end dedupe — and is that read in its documentation or contract, not in a comment (precedent 34)? Is the key stable across in-call attempts **and** across the job's own retries (minted from the business id, never inside the retried callback or the job body)? A 429 from a throttler that answered before any work (a refresh POST retried only on 429) is safe. Proxy-level retries are infrastructure's. **Finding when** a write can be applied twice by a retry after a lost response. **Proof (V307):** a mock that applies the call, then drops the response: the retry carries the same key and the provider records 1 effect after the fix (2 before).

### 6. State not reset, or a retry repeats an earlier step (RT-6)

```
BAD:   pRetry(() => this.em.flush()) around an EntityManager that a failed flush left dirty · a transaction opened outside the retried block
       a stream body re-sent on attempt 2 (already consumed) · a handler that sends an email, then calls a carrier that fails: the job retry re-sends the email
       attempts++ in the loop and again in the catch (half the cap)
GOOD:  the unit of work created inside the retried function (em.fork, a new session) · a per-step checkpoint or reserve
       (step k done → skip on retry) · retry only the failing step · a rebuilt body per attempt
```
**Read the code:** What did attempt 1 leave behind — a flushed row, a persisted entity in an identity map, a consumed stream, a temp file, a first effect? Does the retry run the whole handler again (a job's `attempts`) or only the failing call? Is there a step ledger (`(jobId, step)` reserve) or an idempotent first step? A job that repeats its effect on **redelivery** is `messaging-scan` § 2. **Finding when** attempt 2 repeats an effect of attempt 1 or fails on its leftovers. **Proof (V308):** fail after partial work on attempt 1: attempt 2 succeeds and leaves exactly one result; the first effect is not repeated.

### 7. Deadline not propagated across a hop (TD-1)

```
BAD:   a gRPC Node or Python handler calling out with no deadline taken from the call (RY11: propagation is manual there)
       queue.add('send-otp', d) with no expiresAt, consumed an hour later · a worker that never reads a deadline header
       Promise.race([work(), timeout(30_000)]) — the promise rejects, the work keeps running (precedent 36)
GOOD:  deadline: call.getDeadline() passed on · an expiresAt on the command, checked first, expired → dropped or dead-lettered
       an AbortSignal (or a cancelled context) threaded into every outbound call: AbortSignal.any([own, ambient])
```
**Read the code:** Where does the caller's budget live (a context, an async-local `AbortSignal`, `grpc-timeout`, a message field), and does every hop carry it? Does a job deadline **abort** the work or only reject the promise? Is lateness harmful for this command (an OTP, a reservation hold, a quote) or harmless (a report, a cleanup — precedent 18)? Go `context.Background()` in a synchronous handler is perf § 12. **Finding when** work continues, or a command is applied, after the caller's deadline has passed. **Proof (V309):** with a caller budget B and a slow mock, the outbound call carries ≤ the remaining budget, the callee stops within a margin of cancel, and an expired command is dropped or dead-lettered.

### 8. Timeout order inverted (TD-2)

```
BAD:   a 30 s job deadline over an outbound tier of 15 s × 3 attempts + backoff (55 s) · a .NET TotalRequestTimeout below AttemptTimeout × attempts
       a Temporal Start-To-Close longer than the workflow's business deadline · a queue visibility shorter than attempts × timeout (→ messaging-scan §6)
GOOD:  inner × attempts + Σ backoff < the outer budget with margin, or every attempt races the outer signal so the overrun is cut, not orphaned
```
**Read the code:** Take each worksheet's per-attempt timeout, attempts and backoff cap (0b, F2) and the outer budget (8, F2's resolved deadline); write the arithmetic with both ends cited. Does each attempt race the outer deadline (then the overrun is wasted attempts, not orphaned work: Hardening by precedent 36)? The platform chain (LB, proxy, Lambda) is infrastructure's. **Finding when** the inner chain can outlive the outer budget and keep working after the caller has given up or retried. **Proof (V309):** the static fact with both terms cited; the deadline-budget test settles it.

## Output

```
retry-deadline-scan coverage — scope=whole repo  libs=undici 6.28.1 (apps/api/package.json:81), bullmq ^6.3.4 range · AWS SDK: s3 only, AWS_NEW_RETRIES_2026 unset
Inventory: 0 library constructs, 1 outbound kernel (outbound-http.ts), 69 policy lines (12 POST opt-ins) · outer: 7 consumers, attempts 3 / 5 (F2)

Findings: 0 · Unconfirmed: 2

1. apps/api/src/queue/processors/webhook.processor.ts:650 → orders.client.ts:92                [report-flagged]   medium
   §4 RT-4 — part A: a job retry (attempts 5, webhook.producer.ts:30) over a kernel retry (attempts 3). Part B: the fetch failure is
   rethrown (webhook.processor.ts:676), so one failing delivery makes up to 15 GETs on the provider. Settling test: V306 — count calls.
2. apps/api/src/integrations/connector.ts:196                                                  [report-flagged]   medium
   §5 RT-5 — a webhook-registration POST with an in-call retry; the comment's safety rests on the caller's list diff, which the in-call
   retry does not re-run (precedent 34). Settling test: V307 — succeed, drop the response, count subscriptions.

Verification: 9 candidates → 0 findings · 2 unconfirmed · 1 hardening · 6 refuted (self-verified)

Class coverage:
  §1 retry IF                checked (kernel classifies: timeout, network, 5xx, 429 → 0)
  §2 cap                     checked (0; 4 endless-loop files read: kernel loop bounded by attempts)
  §3 backoff / jitter        checked (full jitter in the kernel; F2 backoff jittered → 0)
  §4 layers                  checked (5 chains → 1 unconfirmed; the 3 × 3 refuted: inner failure caught)
  §5 non-idempotent          checked (12 POST opt-ins → 1 unconfirmed) · → security logic-race-scan §4 (0)
  §6 state / repeated step   checked (4 files read → 0)
  §7 propagation             checked (AbortSignal.any into every attempt, precedent 36 → 0)
  §8 timeout order           checked (30 s job vs 55 s chain → 1 hardening, cut by the abort)
  → infrastructure edge-reliability-scan §3 (0) · serverless-reliability-scan §6 (0) · performance request-path-scan §12 (timeout values)
```

`[report-with-fix]`: the oracle failed on the unfixed code and the fix is local to the project's kernel, retry constant, base producer or deadline helper. `[report-flagged]`: one link is unproven (no oracle run, the far end's dedupe unread, a propagation read pending); the row names it and the settling test. The coverage block lists **all eight** §s on every run: `checked (k → m)`, `N/A (<reason>)`, `blocked (<grep or tool>)`, `→ <owner> (k)` or `Live, unreviewed (k groups / n hits)`. A missing line is printed by `/ds-audit` as `not run`.

## False positives / precedents

The verifier reads [`precedents/distributed.md`](../../../../snippets/precedents/distributed.md) and [`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 4. The ones this skill meets most:
- **§ 14** exactly one retrying layer per chain is the design; a missing circuit breaker alone is Hardening (RES-2 allows `n-a`). *Flips back:* two or more layers retry the same call.
- **§ 15** library defaults decide "no cap / no backoff / no jitter" — read them at the installed version (RY1–RY12, KA7, WK6).
- **§ 16** polling, spin-locks, periodic jobs and reconnect loops are not retry — part A first.
- **§ 17** retrying a POST is safe when the server stores the client's key — verify the server half.
- **§ 18** background work without a deadline is fine when lateness is harmless; not for an OTP, a hold or a quote.
- **§ 34** a comment saying "idempotent" or "the far end dedupes" is a pointer, not evidence; **§ 36** a deadline that aborts the work is the control for a retry running beside the original.
- **§ 32** one replica does not make retry classes latent; **§ 33** a mocked dependency proves call counts (V306) but not a provider's dedupe (V307 needs the provider's documented behaviour or its sandbox).

Shapes this skill must not report: a reconnect loop with jitter; a poll with a fixed interval; a POST retried only on 429 from a throttler that answers before any work; an inner client that catches its exhausted failure and returns a result (the chain ends there); jittered retries reported as unjittered because the jitter lives in a helper in another file.

## When to run

- On any diff that touches a retry library or option (`attempts`, `retry:`, `@Retryable`, `autoretry_for`), a client factory or outbound kernel, a resilience handler, a deadline helper, or a job's options — `/ds-audit`'s "a retry library, a retry option, a client factory, a deadline" signal.
- On every whole-repo `/ds-audit`, reporting all eight §s; before adding a retry to a client, raising a job's `attempts`, or wrapping an SDK call.
- When backend `api-consistency-audit` #13 hands a retry-on-POST row over (§5).

## Halt conditions

- **Do not report a high row as confirmed until a separate verifier has refuted it.** That verifier is not the run that found it, gets part A, part B, the `broker-semantics.md` row, the version lines and the worksheet — never this run's reasoning — and passes the row through [`refute-before-report.md`](../../../../snippets/refute-before-report.md) with `precedents/distributed.md`. Under `/ds-audit` that is its verification phase; standalone, apply the protocol as a second pass and tag the rows `self-verified`. List the rest as Unconfirmed, Hardening, Pre-existing or Refuted; never drop them.
- **Halt before any § when F1 is unread.** A verdict resting on a library default whose version is unknown is invalid; when no RY row covers the installed range, the row is Unconfirmed with the release notes to read.
- **Halt on any §4 row without a filled worksheet** (every layer with `file:line`, attempts, source and the propagation read) and any §8 row without both terms cited.
- **No `[report-with-fix]` without an oracle that failed** (V304–V309) or, for §8, the two-ended static fact.
- **No proposed fix may trade a duplicate for a loss** without a written decision: removing a retry from a read that the user needs, or `attempts: 1` on a job whose only recovery was the retry. The fix keeps one retrying layer, it does not delete them all.
- **Never call a real dependency, provider sandbox or cloud API** from an oracle without the user's explicit go; mocks and containers only. Never run `kubectl`, `helm` or a cloud CLI against a default context or profile.
- **Do not grade a class another pack owns** (§ Boundaries); route it with a count. **Do not print "retries look fine"** without the coverage block; a § whose grep did not run is `blocked`, never `checked`.

## Related

- `/ds-audit` dispatches this skill and folds its coverage block into the Retries and Deadlines ledger rows; `/ds-fix` closes findings with the failing-first oracle, the fix at the root primitive (the kernel, the constant, the base producer), a re-run of this detector and the sibling sweep.
- `messaging-scan` (its `ds-inventory.py` is F2), `dual-write-scan`, `coordination-scan` and `workflow-scan` (this pack) are the neighbours named in § Boundaries; `@resilience-reviewer` judges these rows.
- performance `request-path-scan` § 12, § 14; infrastructure `edge-reliability-scan` § 1, § 3 and `serverless-reliability-scan` § 6; security `logic-race-scan` § 4; backend `api-consistency-audit` #13; mobile `offline-sync-scan` § 2 — when those packs are installed.

## Sources

Defaults are rows of `broker-semantics.md` § Retry and deadline libraries (RY1–RY12, read 2026-10-14 from tenacity, p-retry, axios-retry, got, go-retryablehttp, Polly, learn.microsoft.com *Build resilient HTTP apps*, Resilience4j, the Spring Framework 7.0 reference, the AWS SDKs and Tools reference *Retry behavior*, grpc.io *Deadlines*, cenkalti/backoff) and BQ2, CY3, KA7, LO2, WK6. Amplification figures: the AWS Builders' Library *Timeouts, retries and backoff with jitter* and Google SRE ch. 21–22. IF/WHEN/HOW and the false-positive classes: WASABI (SOSP '24). The axios-retry POST measurement is the backend research lab (2026-10-14). Grep literals: the distributed-systems research catalog R-1…R-7, T-1, T-2 and notes C, and the batch-5 eval § 3.2 DS-3 and § 3.8, re-measured here. Paraphrased; no text reproduced beyond identifiers.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.