Fix /api/map timing out to a 500 with an empty list - #14
Merged
Conversation
`www.sonarsource.com` (and other sites) returned `500 "Discovery timeout after 30000ms"` with zero URLs, while `docs.sonarsource.com` returned thousands. Two bugs combined: 1. map.controller wrapped discovery in `Promise.race([discovery, reject- after-timeout])`. The instant the deadline passed, the race REJECTED and every URL already discovered was thrown away -> 500 empty. A slow but successful crawl looked identical to a hard failure. 2. In discoverUrls, the internal AbortController was dead code (its signal was never passed to any fetch), and the slow phases — subdomain sitemap expansion and browser crawl — were unbounded `await`s. For a seed like www.<domain> with includeSubdomains, subdomain expansion pulls each sibling's sitemap, and a big `docs.` sitemap alone blows the whole budget. Fix — treat timeoutMs as a SOFT deadline and always return what was found: - New pure `discovery-deadline.ts`: `withDeadline(p, ms, fallback)` resolves to `fallback` instead of rejecting; `makeDeadline(totalMs, now?)` tracks the remaining budget (injectable clock). Unit-tested. - discoverUrls: Phase 1 methods now collect URLs as each settles (a hung sitemap can't erase the others), and are awaited only up to the deadline. Subdomain expansion and browser crawl run only while budget remains and are each bounded by `withDeadline`. The method always resolves with the accumulated, filtered URLs and sets `partial: true` when truncated. - Controller drops the reject-race and surfaces `partial` in the response. - Partial (timed-out) results are not cached, so a truncated list can't poison the cache for the full TTL. - Default timeoutMs 30s -> 60s: the deadline is only a ceiling (fast sites still return in 1-3s), and this lets legitimately large sites finish before going partial. Verified in Docker against the live sites: www.sonarsource.com now returns 200 with 100 URLs (was 500-empty); docs.sonarsource.com still returns its big list; fuel-finder.uk unaffected; a forced 2.5s timeout returns 200 + partial:true (not 500) and is not cached. tsc, eslint, 186 tests (incl. 6 new), and openapi:check all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
POST /api/maponhttps://www.sonarsource.com(with the defaultincludeSubdomains:true) returned:…while
docs.sonarsource.comreturned thousands of URLs. A slow-but-successful crawl was indistinguishable from a hard failure. Two bugs combined:map.controller.tsusedPromise.race([discovery, rejectAfter(timeoutMs)]). The moment the deadline passed, the race rejected and discarded every URL already found → 500 with an empty list.discoverUrls, the internalAbortControllerwas dead code (its signal was never wired to any fetch), and Phase 1.5 (subdomain sitemap expansion) + Phase 2 (browser crawl) were plainawaits. For awww.<domain>seed withincludeSubdomains, subdomain expansion pulls each sibling's sitemap — anddocs.sonarsource.com's huge sitemap alone blows the whole 30s budget. (That's exactly why baredocs.worked butwwwdidn't.)Fix — treat
timeoutMsas a soft deadline, always return what was foundsrc/services/discovery-deadline.ts(pure, unit-tested):withDeadline(p, ms, fallback)resolves tofallbackinstead of rejecting;makeDeadline(totalMs, now?)tracks the remaining budget with an injectable clock.discoverUrls: Phase 1 methods now collect URLs as each settles (a hung sitemap can't erase the others) and are awaited only up to the deadline. Subdomain expansion and browser crawl run only while budget remains and are each bounded bywithDeadline. The method always resolves with the accumulated, filtered URLs and setspartial: truewhen truncated.partialin the response metadata.timeoutMs30s → 60s. The deadline is only a ceiling (fast sites still return in 1–3s); this lets legitimately large sites finish before going partial. Preserves the existing speed tiering (sitemap-first; browser crawl only when sparse).Verification (Docker, against the live sites)
www.sonarsource.com+includeSubdomainsdocs.sonarsource.comwww.fuel-finder.uktimeoutMs:2500partial:true, returns what it found, not cachedfromCache:true(complete runs only)tsc✅ ·eslint✅ · 186 tests ✅ (180 + 6 new deadline tests) ·openapi:check✅🤖 Generated with Claude Code