Skip to content

fix(hardening): 503 means unavailable, and an unrouted quote-reply says so (TASK-099 sites 4, 7) - #1523

Open
lilyshen0722 wants to merge 1 commit into
mainfrom
fix/task-099-sites-4-and-7
Open

lilyshen0722 wants to merge 1 commit into
mainfrom
fix/task-099-sites-4-and-7

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

Closes the last two code sites of TASK-099's silent-failure sweep. Site 8 (the
floating void class) 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 every
throw out of summarizeAllPosts reported as a transient outage. 503 is an
instruction to retry, and a code defect never succeeds on retry. Measured
before the fix by @sprint-review: injecting (undefined).boom() at the top of
summarizeAllPosts returned 503 summary_unavailable, and #1501's own
fail-closed test still passed.

The route's comment names two causes. Those two are now tagged at the throw
site
with code: 'summary_unavailable':

  • the rate-limit cooldown throw, and
  • a generateText failure.

The route 503s on that tag and 500s on everything else — a Mongo error, a
TypeError in the post mapping.

Two things worth a reviewer's eye:

  1. The LLM error's message is interpolated into the wrapper, not replaced.
    The outer catch in summarizeAllPosts still scans that string for '429' /
    'Resource exhausted' to arm the cooldown, so replacing the text would
    silently disarm the cooldown. There is a test asserting the original message
    survives.
  2. The route reads the marker as
    require('../services/summarizerService').SUMMARY_UNAVAILABLE || 'summary_unavailable'.
    The || is not defensive noise. Several suites mock
    services/summarizerService wholesale, and a bare destructure of an absent
    export yields undefined — which would then match every error carrying no
    code and restore the exact unconditional-503 behaviour this PR
    removes
    . Matching on .code rather than instanceof for the same class of
    reason: the module ships through the module.exports = exports.default
    CJS-compat shim, and a duplicated module registration breaks identity while
    a string property survives it.

Site 7 — routeReplyContent

This 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 not
the same fact:

  • this was not a quote-reply at all, and
  • this was a quote-reply and the relayMap did not contain it.

The second is the failure. relayMap is $sliced to the newest
RELAY_MAP_CAP (100) entries while Telegram scrollback keeps every relayed
message 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 relayTelegramMessageToPod logs the miss with the
tgMessageId, the map size and the cap, then propagates replyStatus in its
return.

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

  • 57/57 across the seven touched suites under node@22, including
    __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.
  • Mutation, three arms, each discriminating:
    • route back to an unconditional 503 → the two 500 cases go red (2 failed, 3 passed).
    • rethrow the LLM error untagged → the service tagging case goes red (1 failed, 5 passed).
    • collapse the unmatched branch onto 'not-a-reply' → three cases go red (3 failed, 12 passed).
  • Backend typecheck: 50 errors, every one pre-existing, none in any changed file.
  • Zero open PRs touch any of these paths (checked by path before editing).

Stated limit: I could not run npm run lint:ts locally — this worktree's
node_modules is symlinked from a checkout predating #1324 and has no
@typescript-eslint/parser. backend/package.json declares it, so CI's
Backend TypeScript lint step will. I hand-checked the one lint rule I could
reason about statically (max-len, warn at 120) and wrapped the one line that
exceeded it.

🤖 Generated with Claude Code

…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 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 redanswers 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 redtags 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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant