fix(hardening): 503 means unavailable, and an unrouted quote-reply says so (TASK-099 sites 4, 7) - #1523
fix(hardening): 503 means unavailable, and an unrouted quote-reply says so (TASK-099 sites 4, 7)#1523lilyshen0722 wants to merge 1 commit into
Conversation
…ys so (TASK-099 sites 4, 7) Two remaining sites from the silent-failure sweep, both instances of the same discriminator: a failure returning a value the caller cannot tell from success. Site 4 — GET /api/summaries/all-posts. #1501 stopped the summarizer fabricating filler and made the handler fail closed with 503, but unconditionally: every throw out of summarizeAllPosts reported as a transient outage, so a TypeError in the post mapping told the caller to retry an endpoint that is permanently broken. The two causes the route's own comment names — the rate-limit cooldown throw, and a generateText failure — are now tagged at the throw site with code 'summary_unavailable', and only those 503. Everything else is 500. The LLM error's message is interpolated into the wrapper rather than replaced, because the outer catch still scans that string for '429' / 'Resource exhausted' to arm the cooldown. Site 7 — routeReplyContent. The class's non-catch member: no exception, so a catch-keyed scan cannot find it. It answered {content, routedAgent: null} for two conditions that are not the same fact — "this was not a quote-reply" and "this WAS one and the relayMap did not contain it". The map is capped at RELAY_MAP_CAP and Telegram scrollback outlives it, so a human long-pressing an older relayed line expressed routing intent that degraded into an unaddressed broadcast with nothing to show for it. A third field, replyStatus, separates them, and the inbound relay logs the miss with the tgMessageId and map size. ROUTING IS UNCHANGED: an unmatched quote-reply still relays, still unaddressed. Only the reporting is new. Residue, named not built: the operator can now see an unmatched quote-reply; the Telegram user still cannot. Telling them changes what lands in the pod, which is a product call rather than a silent-failure fix. Verified: 57/57 across the seven touched suites under node@22, including the service-tier summaries suite whose end-to-end 503 assertion still passes through the real code path. Both fixes proved by mutation — reverting the route to an unconditional 503 reds the two 500 cases; rethrowing the LLM error untagged reds the service tagging case; collapsing the unmatched branch back onto 'not-a-reply' reds three. Backend typecheck: 50 errors, all pre-existing, none in any changed file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lilyshen0722
left a comment
There was a problem hiding this comment.
SR-GATE: APPROVED @ bf0fda9d — both sites closed, and both fixes discriminate.
Baseline 26/26 (Node 22). Three mutations, each anchor asserted to apply exactly once:
| mutation | result |
|---|---|
| site 4: make the route's 503 unconditional again | 2 red — answers 500 … for a code defect inside the summarizer, answers 500 for a datastore failure |
site 4: throw the raw llmError instead of tagging it |
1 red — tags an LLM failure with code 'summary_unavailable' |
site 7: report an unmatched reply as not-a-reply |
3 red — absent entry, aged-out entry, entry with no agentUsername |
Two judgement calls I checked rather than took on trust
Matching on .code instead of instanceof is correct here, for the stated reason. The foot of summarizerService.ts is module.exports = exports["default"]; Object.assign(module.exports, exports); — a duplicated module registration would give two distinct SummaryUnavailableError constructors and break identity, while a string property survives it.
The || 'summary_unavailable' on the require is load-bearing, and the comment slightly undersells why. That Object.assign line means require(...).SUMMARY_UNAVAILABLE does resolve in production, so the fallback only fires under a wholesale service mock — which is exactly what the comment says. Worth noting it is not dead code in the way a reader might assume, because without it an undefined sentinel would match every code-less error and silently restore the unconditional 503 this PR removes. The reasoning is sound as written.
Site 7's severity claim holds. RELAY_MAP_CAP = 100 at :26, applied as $slice: -RELAY_MAP_CAP at :190, so entries genuinely age out and a human quoting older Telegram scrollback expresses routing intent that used to vanish into a return value byte-identical to "not a reply". The fix changes no routing — an unmatched reply still relays — and the log carries entries and cap, which is what makes the next report diagnosable.
Site 7 is also the member of this class that my own catch-keyed scans structurally could not find, and the comment says so at the site. Good place for it.
Non-blocking
The cooldown throw is now a SummaryUnavailableError whose message is 'All-posts summary generation is cooling down after an LLM rate limit', and the outer catch still arms the cooldown by scanning for '429' / 'Resource exhausted'. Neither string is in that message, so it still cannot feed itself — the property I checked on #1501 survives this refactor. Stating it because the interpolation in the new LLM-failure path (… failed at the LLM: ${message}) deliberately preserves that scan, and a future edit that "cleans up" the message would break it silently.
Scope limits
Test & Coverage and E2E Tests are pending; this rests on the three suites I ran plus the mutations. Backend only — no frontend files here.
With this, TASK-099's code sites are done except 8, which is parked on the no-floating-promises / parserOptions.project prerequisite.
Closes the last two code sites of TASK-099's silent-failure sweep. Site 8 (the
floating
voidclass) is parked on a rule decision and is not in here.Both sites are the same discriminator @sprint-review and I converged on: a
failure is silent iff the returned value is reachable on the success path
without being the documented fallback. Sites 1, 2, 3, 5, 6 and 9 landed in
#1519.
Site 4 —
GET /api/summaries/all-posts#1501 stopped the summarizer fabricating filler and made this handler fail
closed with
503 summary_unavailable. It did so unconditionally, so everythrow out of
summarizeAllPostsreported as a transient outage. 503 is aninstruction to retry, and a code defect never succeeds on retry. Measured
before the fix by @sprint-review: injecting
(undefined).boom()at the top ofsummarizeAllPostsreturned503 summary_unavailable, and #1501's ownfail-closed test still passed.
The route's comment names two causes. Those two are now tagged at the throw
site with
code: 'summary_unavailable':generateTextfailure.The route 503s on that tag and 500s on everything else — a Mongo error, a
TypeErrorin the post mapping.Two things worth a reviewer's eye:
The outer catch in
summarizeAllPostsstill scans that string for'429'/'Resource exhausted'to arm the cooldown, so replacing the text wouldsilently disarm the cooldown. There is a test asserting the original message
survives.
require('../services/summarizerService').SUMMARY_UNAVAILABLE || 'summary_unavailable'.The
||is not defensive noise. Several suites mockservices/summarizerServicewholesale, and a bare destructure of an absentexport yields
undefined— which would then match every error carrying nocodeand restore the exact unconditional-503 behaviour this PRremoves. Matching on
.coderather thaninstanceoffor the same class ofreason: the module ships through the
module.exports = exports.defaultCJS-compat shim, and a duplicated module registration breaks identity while
a string property survives it.
Site 7 —
routeReplyContentThis is the class's non-catch member. There is no exception; it is a
control-flow miss returning the success-path value, which is why a catch-keyed
scan — including the one that produced this row's inventory — cannot find it.
The discriminator diagnoses it anyway.
It answered
{ content, routedAgent: null }for two conditions that are notthe same fact:
relayMapdid not contain it.The second is the failure.
relayMapis$sliced to the newestRELAY_MAP_CAP(100) entries while Telegram scrollback keeps every relayedmessage long-pressable, so a human quote-replying to an older line expresses
routing intent that degrades into an unaddressed broadcast: no mention lands,
nothing wakes, and the return value is byte-identical to the ordinary
no-reply success.
A third field,
replyStatus: 'not-a-reply' | 'routed' | 'unmatched',separates them, and
relayTelegramMessageToPodlogs the miss with thetgMessageId, the map size and the cap, then propagatesreplyStatusin itsreturn.
Routing is unchanged. An unmatched quote-reply still relays, still
unaddressed. Only the reporting is new — falling closed here would drop a
legitimate message, and routing it somewhere by guess is a different decision.
Residue, named rather than built
The operator can now see an unmatched quote-reply. The Telegram user still
cannot. Telling them means changing what lands in the pod (or replying into
the chat), which is a product call, not a silent-failure fix. Flagging it here
so it is a decision someone makes rather than a gap nobody noticed.
Verification
__tests__/service/summaries.test.js— its end-to-end"fails closed with 503 when no LLM can generate the summary" assertion still
passes through the real code path, not a mock, which is the evidence that
the tagging reaches production shape.
unmatchedbranch onto'not-a-reply'→ three cases go red (3 failed, 12 passed).Stated limit: I could not run
npm run lint:tslocally — this worktree'snode_modulesis symlinked from a checkout predating #1324 and has no@typescript-eslint/parser.backend/package.jsondeclares it, so CI'sBackend TypeScript lintstep will. I hand-checked the one lint rule I couldreason about statically (
max-len, warn at 120) and wrapped the one line thatexceeded it.
🤖 Generated with Claude Code