refactor(server): extract generic webhook handler factory to eliminate triplicated code#496
Conversation
…e triplicated code
|
🔍 Reviewing PR... |
nhopeatall
left a comment
There was a problem hiding this comment.
Summary
Clean, well-executed refactoring that correctly eliminates ~150 lines of triplicated webhook handler code. The factory pattern is well-designed with good separation of concerns. CI is green and the new tests are thorough.
Two behavioral changes in the router deployment mode are worth noting — one is a should-fix, one is informational.
Code Issues
Should Fix
-
src/router/index.ts — The old router handlers called
handleTrelloWebhook/handleGitHubWebhook/handleJiraWebhooksynchronously (awaited) before responding. This ensured the job was queued into the job queue before the 200 was returned to the webhook source. The new factory wraps all processing insetImmediate, making it fire-and-forget. This means the HTTP 200 is sent before the job is queued.In
server.tsthis is fine (and was the existing behavior). But in the router, this is a real regression: if the process crashes between sending the 200 andsetImmediatefiring, the webhook event is lost without the sender knowing. The old code guaranteed that a 200 meant "job queued." Consider either (a) makingprocessWebhookawaited rather than fire-and-forget, or (b) adding afireAndForgetoption to the factory config that the router can set tofalse. -
src/server/webhookHandlers.ts:113-121 — The webhook log now always records
processed: truewhen parsing succeeds. In the oldrouter/index.ts, the Trello handler loggedprocessed: shouldProcess && !!project && !!cardId(reflecting whether the event was actually relevant and matched a project). Similarly,projectIdwas included in the old log but is now absent. This reduces observability — the dashboard webhook log viewer will show all successfully-parsed webhooks as "processed" even if they were filtered out as irrelevant events.This is less critical for
server.tswhere the old code already loggedprocessed: trueunconditionally. But for the router path, this is a loss of signal. Consider extendingWebhookHandlerConfigto accept an optionalresolveLogFieldscallback that can enrich the log after processing (or at minimum, document this as a known trade-off).
Informational
- The factory correctly defaults
checkCapacitytotrue, and the router explicitly passesfalse— this is correct and well-documented. - The reaction builders faithfully reproduce the old platform-specific logic.
- The
parseGitHubPayloadcorrectly delegates to the existingparseGitHubWebhookPayloadfor form-encoded support.
| logger.error(`Error processing ${source} webhook`, { | ||
| error: String(err), | ||
| stack: err instanceof Error ? err.stack : undefined, | ||
| }); |
There was a problem hiding this comment.
The factory always uses setImmediate for fire-and-forget processing. This matches the old server.ts behavior but changes the old router/index.ts behavior, which awaited processing (job queuing) before responding.
Consider adding a fireAndForget?: boolean option (defaulting to true) so the router can opt into synchronous processing:
if (fireAndForget) {
setImmediate(() => { processWebhook(...).catch(...); });
} else {
await processWebhook(payload, eventType).catch(...);
}This preserves the guarantee that the router's 200 means 'job queued.'
There was a problem hiding this comment.
Addressed. Added a fireAndForget option to WebhookHandlerConfig (defaults to true for backward compatibility). The router now passes fireAndForget: false, restoring the original behavior where processWebhook is awaited before responding — so 200 means "job queued."
In the factory implementation:
fireAndForget: true(server mode): logs, then schedulesprocessWebhookviasetImmediate, returns 200 immediatelyfireAndForget: false(router mode): awaitsprocessWebhook, then logs, then returns 200
6 new unit tests cover this behavior.
| body: payload, | ||
| statusCode: 200, | ||
| eventType, | ||
| processed: true, |
There was a problem hiding this comment.
In the old router/index.ts, the Trello webhook log included projectId: project?.id and processed: shouldProcess && !!project && !!cardId, reflecting actual processing outcome. The factory now always logs processed: true on parse success, losing that signal.
This is a minor observability regression for the router deployment mode. The dashboard webhook log viewer will no longer distinguish between webhooks that were actually processed vs. filtered out as irrelevant.
There was a problem hiding this comment.
Addressed. Added a resolveLogFields callback to WebhookHandlerConfig that allows callers to override the processed and projectId fields in the webhook log.
The router now uses this to capture the actual return values from handleTrelloWebhook / handleGitHubWebhook / handleJiraWebhook and pass them through:
- Trello:
processed: shouldProcess && !!project && !!cardId,projectId: project?.id - GitHub:
processed: shouldProcess - JIRA:
processed: !!shouldProcess,projectId: project?.id
This fully restores the original observability in the webhook log viewer. When fireAndForget: false, resolveLogFields runs after processWebhook completes, so it reflects actual processing outcome.
nhopeatall
left a comment
There was a problem hiding this comment.
LGTM with minor suggestions — see inline comments about behavioral changes in router deployment mode (fire-and-forget processing timing, webhook log accuracy).
|
@aaight address code review concerns |
Request AddressedWhat was askedAddress code review concerns from @nhopeatall's review. Changes Made
Verification
|
…ogFields options - Add `fireAndForget` option to `createWebhookHandler` (default: true). When false, `processWebhook` is awaited before responding, ensuring the router's 200 means "job queued" (restoring original router behavior). - Add `resolveLogFields` callback to enrich webhook log entries with `processed` and `projectId` based on actual processing outcome (restoring original router observability). - Update router/index.ts to use both options, capturing handler return values and mapping them to log fields via closures. - Add 6 new unit tests covering fireAndForget=false, resolveLogFields, and their interaction. - Extract helper functions to reduce cognitive complexity below threshold. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
🔍 Reviewing PR... |
nhopeatall
left a comment
There was a problem hiding this comment.
Summary
Good refactoring that successfully eliminates ~150 lines of triplicated webhook handling code. The factory pattern is well-designed, the type definitions are clear, and the new test suite is thorough. CI passes, existing tests unaffected. Two items worth noting:
Code Issues
Should Fix
-
src/router/index.ts:49,86,118 — The
lastResultclosure pattern used to communicate data betweenprocessWebhookandresolveLogFieldshas a race condition under concurrent requests. SincelastResultis shared mutable state in the closure, two concurrent webhook requests to the same endpoint can interleave: Request A setslastResult, then Request B setslastResult, then Request A readslastResultand gets B's data. This only affects webhook log accuracy (wrongprocessed/projectIdin logs), not the HTTP response, so the blast radius is small. But it could be avoided by havingresolveLogFieldsreceive the result fromprocessWebhookdirectly (e.g., the factory could pass a per-request context object, orprocessWebhookcould return the log fields). -
src/server/webhookHandlers.ts:201 — In
fireAndForget: falsemode,processWebhookerrors are caught and swallowed (.catch((err) => handleProcessingError(source, err))) and the handler still returns 200. This is a behavioral change from the old router code, where an exception fromhandleTrelloWebhook/handleGitHubWebhook/handleJiraWebhookwould propagate to Hono's error handler and result in a 500 response. For webhook endpoints, returning 200 on failure may actually be preferable (avoids webhook platform retries causing duplicate processing), but this is a deliberate semantic change worth acknowledging.
| app.post( | ||
| '/trello/webhook', | ||
| (() => { | ||
| let lastResult: { shouldProcess: boolean; projectId?: string } | undefined; |
There was a problem hiding this comment.
[SHOULD_FIX] The lastResult closure variable is shared across all concurrent requests to this endpoint. If two Trello webhooks arrive concurrently and their processWebhook calls interleave, one request's resolveLogFields can read the other request's result.
Since fireAndForget: false means both processWebhook and resolveLogFields are awaited within the same handler invocation, the interleaving happens when the first request suspends at its await handleTrelloWebhook(...) and the second request completes its processing before the first resumes.
The impact is limited to incorrect log entries (wrong processed flag or projectId), but it's a subtle bug that could be confusing during debugging.
Consider having the factory accept a processWebhook that returns the log overrides directly, eliminating the need for shared mutable state:
processWebhook: async (payload) => {
const { shouldProcess, project, cardId } = await handleTrelloWebhook(payload, triggerRegistry);
return {
processed: shouldProcess && !!project && !!cardId,
projectId: project?.id,
};
},With the factory using the return value for log enrichment instead of a separate resolveLogFields callback.
There was a problem hiding this comment.
Addressed. Replaced the lastResult closure pattern + resolveLogFields callback with a return value from processWebhook. The factory now uses the return value directly for log enrichment — this is request-scoped and inherently safe under concurrent requests (no shared mutable state).
The router webhook handlers are also much simpler now — the IIFE closures are gone entirely:
processWebhook: async (payload) => {
const { shouldProcess, project, cardId } = await handleTrelloWebhook(payload, triggerRegistry);
return {
processed: shouldProcess && !!project && !!cardId,
projectId: project?.id,
};
},In fireAndForget: true mode (server), the return value is ignored since logging happens before processing starts.
| processWebhook(payload, eventType).catch((err) => handleProcessingError(source, err)); | ||
| }); | ||
| } else { | ||
| // --- Await processing then log (router mode) --- |
There was a problem hiding this comment.
[SHOULD_FIX] In fireAndForget: false (router) mode, this .catch() swallows processWebhook errors and the handler still returns 200. The old router code let exceptions propagate to Hono's error handler (resulting in 500).
This may be intentionally safer for webhooks (avoids retry storms from the webhook platform), but it's a behavioral change worth documenting. If the old 500-on-error behavior was relied upon for monitoring or alerting, this change would silently break that.
There was a problem hiding this comment.
Addressed. In fireAndForget: false mode, processWebhook errors now propagate directly to Hono's error handler (resulting in 500), matching the old router behavior. The .catch() wrapper has been removed from this code path:
// Before (swallowed errors):
await processWebhook(payload, eventType).catch((err) => handleProcessingError(source, err));
// After (errors propagate):
const logOverrides = await processWebhook(payload, eventType);The .catch() is still used in fireAndForget: true mode (server) where fire-and-forget semantics require it. A new test verifies that errors propagate to Hono's error handler in synchronous mode.
nhopeatall
left a comment
There was a problem hiding this comment.
Review complete - see inline comments for two SHOULD_FIX items regarding the lastResult race condition and error swallowing behavior change.
|
@aaight address code review concerns |
Request AddressedWhat was askedAddress code review concerns from @nhopeatall's second review round (two SHOULD_FIX items). Changes Made
Verification
|
…opagation - Replace `resolveLogFields` callback + shared `lastResult` closure with `processWebhook` return value for log enrichment. This eliminates the race condition where concurrent requests could read each other's results via the shared mutable closure variable. - In `fireAndForget: false` mode, let `processWebhook` errors propagate to Hono's error handler (500) instead of swallowing them with `.catch()`. This restores the old router behavior where a processing failure was not acknowledged with 200. - Simplify router webhook handlers by removing IIFE closures — the factory now handles log enrichment via the processWebhook return value directly. - Update tests to verify new behavior: processWebhook return value used for log enrichment, error propagation in synchronous mode, and fire-and-forget mode ignoring return values. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
🔍 Reviewing PR... |
nhopeatall
left a comment
There was a problem hiding this comment.
Summary
Clean, well-executed refactoring that extracts ~150 lines of triplicated webhook handler logic into a composable factory. The behavioral equivalence is preserved across both deployment modes (server fire-and-forget and router await-then-log), CI is green, and the new unit tests provide strong coverage of the factory's branching logic.
The factory design is sound: createWebhookHandler properly parameterizes the five axes of variation (source, parser, reaction sender, processing mode, capacity checking) while the platform-specific parsers and reaction builders are independently testable. The fireAndForget flag cleanly models the two deployment modes without conditional spaghetti.
No blocking or should-fix issues found. The code is correct, well-documented, and improves maintainability.
nhopeatall
left a comment
There was a problem hiding this comment.
LGTM - Clean refactoring with all prior review feedback addressed. Behavioral equivalence verified across both deployment modes.
Summary
createWebhookHandler()factory insrc/server/webhookHandlers.tsthat replaces three near-identical 50-60 line POST handler blocks in bothsrc/server.tsandsrc/router/index.tsparseTrelloPayload,parseGitHubPayload,parseJiraPayload) and reaction sender builders as composable, independently testable unitsTest plan
tests/unit/server.test.tstests pass unchanged (verified)tests/unit/server/webhookHandlers.test.tscover:Card: https://trello.com/c/aQ9kSuJz/83-find-top-candidate-for-refactoring-and-plan-clean-refactoring-of-it-look-for-god-classes-modules-functions-files-and-code-duplic
🤖 Generated with Claude Code