Skip to content

Commit dfff6fd

Browse files
garethxclaude
andcommitted
fix: round-3 review — deliberate pauses, settle ordering, inert-option honesty
1. A restart lifted deliberate operator pauses. `stop()` stamped `pauseReason: "shutdown"` on every configured cursor, including one already marked `operator` — so an operator pause survived until the Gateway restarted, at which point it was reclassified as a breadcrumb and the next tunnel connect resumed a pipeline someone had stopped on purpose. The round-2 test only covered a reconnect with no restart in between. Operator pauses are now skipped in `stop()`, which also saves re-pausing an already-paused connection against the shutdown budget. 2. The new catch path in settleAfterRun used the settle/retry ordering that the success path fifty lines above explains is wrong. Same file, opposite orderings, each with a comment claiming necessity. It settles first now, matching, and corrects to `exhausted` if the redelivery could not be requested. 3. The startup warnings claimed to name every inert option and did not. Because zod applies defaults, per-field warnings would either fire always or never, so there is now one warning per agent route stating that turns are fire-and-forget, naming maxAgentRetries and syncTimeoutSeconds as never taking effect, saying that a crash mid-run is not re-queued, and listing whichever of deliver/lane/sync were set. Also: a failed operator pause no longer erases an underlying shutdown breadcrumb, and resuming clears the reason rather than resetting it to "shutdown"; `transportNote` reaches the caller instead of being computed and dropped; form and JSON content types are both matched on the parsed mime rather than one by substring; recovery settles on any permanent 4xx rather than 404 alone; a disabled route is no longer paused at shutdown, since in CLI mode nothing would ever resume it; the readiness test imports the pattern rather than restating it; and the outcome.ts contract text and the README's shutdown-budget claim now match what the code does. Tests for what was previously unpinned: recovery settling behaviour across permanent, transient and successful retries; a rejecting waitFor after the 202; template bracket indexes; the fire-and-forget warnings; the deliberate-pause-across-restart case; and a new suite for the config-error path, which had none — covering the 503 hold, basePath sanitising, and the status tool that stays registered. 613 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 83d6ecb commit dfff6fd

19 files changed

Lines changed: 535 additions & 66 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ You do not need to configure destination auth either. CLI destinations default t
9898
| `busyRetryAfterSeconds` | `10` | `Retry-After` sent when deferring at capacity. |
9999
| `deferAttemptLimit` | `5` | Deferrals of the same event before the short `Retry-After` is dropped and exponential backoff takes over. Capacity that has not recovered after this many attempts is not the transient condition a short interval assumes. |
100100
| `pause.onShutdown` | `true` | Pause the connection before stopping the listener, so events are held rather than discarded. |
101-
| `pause.shutdownTimeoutMs` | `5000` | Budget for the whole teardown: pausing, draining and stopping children. |
101+
| `pause.shutdownTimeoutMs` | `5000` | Budget for pausing connections at shutdown. Stopping the CLI children is bounded separately, by a SIGTERM grace before SIGKILL. |
102102
| `catchUp.enabled` | `true` | After a reconnect, replay requests that arrived while nothing was listening. |
103103
| `catchUp.minGapSeconds` | `30` | Below this, an outage is not worth a bulk replay. |
104104
| `dedupe.ttlHours` | `168` | Ledger retention, matching Hookdeck's one-week retry ceiling. Raise it if you extend retries beyond a week. |
@@ -358,7 +358,7 @@ npm test
358358
npm run typecheck
359359
```
360360

361-
586 tests, no Gateway or Hookdeck account required. Signature vectors are computed independently with `openssl`, `test/http-integration.test.ts` exercises the pipeline over a real socket including multi-byte UTF-8 and multi-chunk bodies, the store suites inject write failures at an exact call to prove the degradation rule, and `test/store-io.test.ts` runs against a real filesystem because that is the only place durability actually lives.
361+
613 tests, no Gateway or Hookdeck account required. Signature vectors are computed independently with `openssl`, `test/http-integration.test.ts` exercises the pipeline over a real socket including multi-byte UTF-8 and multi-chunk bodies, the store suites inject write failures at an exact call to prove the degradation rule, and `test/store-io.test.ts` runs against a real filesystem because that is the only place durability actually lives.
362362

363363
## Shared reliability contract
364364

scripts/agent-smoke.sh

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,9 @@ if [ -n "$HOOKDECK_KEY" ]; then
164164
exit 2
165165
fi
166166
if [ -n "$ALLOW_MUTATIONS" ]; then
167-
# Scoped to one connection on purpose. "Acknowledge the oldest issue" would
168-
# let the model pick anything in the project.
167+
# Scoped to one connection on purpose: "acknowledge the oldest issue" would
168+
# let the model pick anything in the project. The connection is required at
169+
# startup, so it is set by the time we get here.
169170
ask "Acknowledge the oldest open Hookdeck issue for the $MUTATION_CONNECTION connection, then confirm what changed and what did NOT change."
170171
else
171172
# The refusal is the point: the correct answer names tools.allowMutations.

src/dispatch/agent.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,16 +173,23 @@ export function createAgentDispatcher(
173173
const message = err instanceof Error ? err.message : String(err);
174174
deps.logger.warn(`background settle failed for ${eventId}: ${message}`);
175175

176+
// Settled BEFORE the retry, matching the success path above: Hookdeck
177+
// can redeliver within milliseconds, and the redelivery's `begin` opens
178+
// the next run's row — which a late settle would stamp `failed`.
179+
// `failed` is the optimistic guess; it is corrected to `exhausted` below
180+
// if the redelivery could not be requested.
181+
await deps.ledger.settle(eventId, "failed").catch(() => {});
182+
176183
const requeued =
177184
deps.client !== undefined &&
178185
(await deps.client
179186
.retryEvent(eventId)
180187
.then((r) => r.ok)
181188
.catch(() => false));
182189

183-
await deps.ledger
184-
.settle(eventId, requeued ? "failed" : "exhausted")
185-
.catch(() => {});
190+
if (!requeued) {
191+
await deps.ledger.settle(eventId, "exhausted").catch(() => {});
192+
}
186193

187194
if (!requeued) {
188195
await deps.deadLetter

src/hookdeck/client.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,10 +262,24 @@ export function createHookdeckClient(
262262
};
263263
}
264264

265+
// `permanent` means a retry of this exact request cannot succeed: the
266+
// event is gone, or the request is malformed or unauthorised. Callers
267+
// use it to decide whether to keep a row for the next attempt or give
268+
// up on it. 429 is excluded deliberately — it is the retryable 4xx.
269+
const permanent =
270+
response.status >= 400 &&
271+
response.status < 500 &&
272+
response.status !== 429;
273+
265274
return {
266275
ok: false,
267276
status: response.status,
268-
code: response.status === 404 ? "not_found" : "api_error",
277+
code:
278+
response.status === 404
279+
? "not_found"
280+
: permanent
281+
? "permanent_error"
282+
: "api_error",
269283
message,
270284
};
271285
}

src/ingress/handler.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,24 +96,28 @@ function parseFormUrlEncoded(body: string): Record<string, unknown> {
9696
return out;
9797
}
9898

99-
function contentTypeIsFormEncoded(
99+
/** The mime type alone, lower-cased, with any parameters dropped. */
100+
function mimeType(
100101
headers: Record<string, string | string[] | undefined>,
101-
): boolean {
102+
): string | undefined {
102103
const raw = headers["content-type"];
103104
const value = Array.isArray(raw) ? raw[0] : raw;
104-
return (
105-
typeof value === "string" &&
106-
value.toLowerCase().includes("application/x-www-form-urlencoded")
107-
);
105+
if (typeof value !== "string") return undefined;
106+
return value.split(";")[0]?.trim().toLowerCase();
107+
}
108+
109+
function contentTypeIsFormEncoded(
110+
headers: Record<string, string | string[] | undefined>,
111+
): boolean {
112+
// The mime, not a substring search over the whole header: a parameter that
113+
// happens to contain this string would otherwise reclassify a JSON body.
114+
return mimeType(headers) === "application/x-www-form-urlencoded";
108115
}
109116

110117
function contentTypeIsJson(
111118
headers: Record<string, string | string[] | undefined>,
112119
): boolean {
113-
const raw = headers["content-type"];
114-
const value = Array.isArray(raw) ? raw[0] : raw;
115-
if (typeof value !== "string") return false;
116-
const mime = value.split(";", 1)[0]?.trim().toLowerCase() ?? "";
120+
const mime = mimeType(headers) ?? "";
117121
return mime === "application/json" || mime.endsWith("+json");
118122
}
119123

src/plugin/config-parse.ts

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -278,31 +278,27 @@ export function parseHookdeckConfig(raw: unknown): ConfigParseResult {
278278
}
279279

280280
if (route.dispatch.mode === "agent") {
281-
// Both are carried in the config and neither reaches the runner: agent
282-
// turns go through TaskFlow `run_task`, which takes a goal and nothing
283-
// else. Saying so is better than an operator setting `deliver: false` and
284-
// believing it is doing something.
285-
if (route.dispatch.deliver) {
286-
warnings.push({
287-
path: `routes.${routeId}.dispatch.deliver`,
288-
message:
289-
"deliver has no effect on the TaskFlow transport and is not passed to the agent turn; it is recorded for a future transport that can carry it",
290-
});
291-
}
292-
if (route.dispatch.lane !== undefined) {
293-
warnings.push({
294-
path: `routes.${routeId}.dispatch.lane`,
295-
message:
296-
"lane has no effect on the TaskFlow transport and is not passed to the agent turn",
297-
});
298-
}
299-
if (route.dispatch.ackMode === "sync") {
300-
warnings.push({
301-
path: `routes.${routeId}.dispatch.ackMode`,
302-
message:
303-
"sync needs a completion signal the TaskFlow transport does not provide; this route will behave as async_retry",
304-
});
305-
}
281+
// Agent turns go through TaskFlow `run_task`, which takes a goal and
282+
// exposes flow state rather than a completion signal. Several dispatch
283+
// options therefore describe behaviour this transport cannot produce.
284+
// One warning per route, naming them: silence would let an operator
285+
// believe a safety setting was in force.
286+
const inert: string[] = [];
287+
if (route.dispatch.deliver) inert.push("deliver");
288+
if (route.dispatch.lane !== undefined) inert.push("lane");
289+
if (route.dispatch.ackMode === "sync") inert.push('ackMode: "sync"');
290+
291+
warnings.push({
292+
path: `routes.${routeId}.dispatch`,
293+
message:
294+
"agent turns are fire-and-forget on the TaskFlow transport: the delivery is acknowledged as " +
295+
"soon as the run STARTS, so nothing waits for it and no run failure is ever observed. " +
296+
"maxAgentRetries and syncTimeoutSeconds therefore never take effect, and a crash mid-run is " +
297+
"not re-queued by recovery — run durability belongs to the flow record" +
298+
(inert.length > 0
299+
? `. ${inert.join(", ")} ${inert.length === 1 ? "is" : "are"} recorded but not passed to the turn`
300+
: ""),
301+
});
306302
}
307303

308304
routes[routeId] = {

src/protocol/outcome.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,14 @@ export function retryable(
142142
* must name a reason from the allowlist, and each emission is logged and
143143
* counted by the caller.
144144
*
145-
* Always dead-letters. If we are telling Hookdeck to stop trying, the payload
146-
* has to survive locally or it is simply lost.
145+
* Dead-letters everything past signature verification. If we are telling
146+
* Hookdeck to stop trying, the payload has to survive locally or it is simply
147+
* lost.
148+
*
149+
* Cancellations BEFORE verification — wrong method, wrong content type,
150+
* oversized — are deliberately not recorded. The ingress is public and the log
151+
* is bounded, so recording unauthenticated traffic would let a scanner evict
152+
* the failures that matter.
147153
*/
148154
export function cancelRetries(
149155
reason: CancelReason,

src/recovery.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -126,23 +126,23 @@ export async function reconcileOrphans(
126126
attemptCount: row.attempt,
127127
});
128128

129-
// A 404 means the event has aged out of Hookdeck's retention: no future
130-
// boot will do better, so the row is settled. Anything else may be
131-
// transient — a network fault, a rate limit — and is left `running` for the
132-
// next boot to retry.
129+
// A permanent failure — the event aged out of retention, the request was
130+
// rejected — will not go better on the next boot, so the row is settled.
131+
// Transient failures (network, rate limit, 5xx) leave it `running` to be
132+
// retried then.
133133
//
134-
// Leaving a permanently dead row running would make it an orphan forever:
135-
// one duplicate dead-letter per boot, and since orphans are recovered
136-
// oldest-first it would consume the budget ahead of events that could
137-
// actually be recovered.
138-
if (result.code === "not_found") {
139-
await ledger.settle(row.eventId, "failed");
140-
}
134+
// Leaving a permanently dead row running makes it an orphan forever: one
135+
// duplicate dead-letter per boot, and since orphans are recovered
136+
// oldest-first it consumes the budget ahead of events that could actually
137+
// be recovered.
138+
const permanent =
139+
result.code === "not_found" || result.code === "permanent_error";
140+
if (permanent) await ledger.settle(row.eventId, "failed");
141141

142142
logger.warn(
143143
`could not re-queue interrupted event ${row.eventId}: ${result.message}` +
144-
(result.code === "not_found"
145-
? " (aged out of retention; not retried again)"
144+
(permanent
145+
? " (permanent; not retried again)"
146146
: " (will be retried on the next start)"),
147147
);
148148
}

src/tools/pause.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,14 @@ export async function pauseHandler(
7676
});
7777
const result = await client.pauseConnection(cursor.connectionId);
7878
if (!result.ok) {
79+
// Restore what was there, rather than asserting "not paused": a
80+
// shutdown breadcrumb underneath this must survive, or the connection is
81+
// left paused with nothing to lift it.
7982
await deps.cursors.patch(params.routeId, {
80-
pausedByUs: false,
81-
pauseReason: "shutdown",
83+
pausedByUs: cursor.pausedByUs === true,
84+
...(cursor.pauseReason !== undefined
85+
? { pauseReason: cursor.pauseReason }
86+
: {}),
8287
});
8388
return { ok: false, note: result.message };
8489
}
@@ -110,10 +115,10 @@ export async function pauseHandler(
110115
cancelPendingAutoResume(params.routeId);
111116
const result = await client.unpauseConnection(cursor.connectionId);
112117
if (!result.ok) return { ok: false, note: result.message };
113-
await deps.cursors.patch(params.routeId, {
114-
pausedByUs: false,
115-
pauseReason: "shutdown",
116-
});
118+
// Cleared, not reset to a value: "resumed" has no pause reason, and leaving
119+
// `shutdown` there would be a breadcrumb for something that already happened.
120+
await deps.cursors.patch(params.routeId, { pausedByUs: false });
121+
await deps.cursors.clear(params.routeId, "pauseReason");
117122
return {
118123
ok: true,
119124
paused: false,

src/tools/replay.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ export async function replayHandler(
149149
return {
150150
ok: false,
151151
dryRun: true,
152+
...(transportNote !== undefined ? { transportNote } : {}),
152153
note:
153154
`Would replay requests for route '${params.routeId}' from the last ${params.sinceMinutes} minute(s) ` +
154155
`that produced no CLI event. Re-run with confirm: true to execute.`,
@@ -175,6 +176,7 @@ export async function replayHandler(
175176
...(beyondShortestRetention
176177
? { retentionWarning: RETENTION_NOTE }
177178
: {}),
179+
...(transportNote !== undefined ? { transportNote } : {}),
178180
}
179181
: { ok: false, note: `replay did not run: ${result.reason}` };
180182
}

0 commit comments

Comments
 (0)