career/qwestly/2026-10-web-fetch-claude-fallback.md
Table of Contents
Made job links from boards that block us (Indeed) readable in role fit and hiring ingestion
Situation
Role fit, hiring ingestion and the internal web-fetch tool all read a pasted job link through one shared api-python endpoint, whose default fetcher is our own HTTP client. Indeed answers that client with HTTP 403, so every Indeed link failed outright and the member got nothing back, even though Claude's server-side fetcher reads the same page fine. In the 30 days before the fix, every production failure of that endpoint was Indeed.
What I did
- Reproduced it against the real link:
directandllm_cleanboth got a 403;claude_web_fetchreturned the posting. - Added a one-shot fallback in api-python: when our own fetch gets a 4xx from the site (other than
404/410), retry through Claude's
web_fetch, and record it inprovenance.fallback_from. If Claude fails too, the original error is returned. On by default, with a kill switch and a separate model setting. - Chose Haiku 5.5 over the Sonnet 5.5 first suggested, after measuring: identical 14,466-char output, 2.3 s vs 9.1 s, and the fewest tokens.
- Found and fixed a latent bug in the Claude strategy: Haiku 5.5 sometimes calls the tool twice and the over-limit second call made us discard the page (2 of 3 attempts failed; 5 of 5 after).
- Checked Perplexity as an alternative and rejected it: Sonar answers questions instead of returning the page and refused to give the posting verbatim.
- Shipped: api-python#237 (released in #238, tag
api-prod-20261009-pr238), qwestly-internal#112. Asana: https://app.asana.com/1/1208967535790507/project/1216663810328535/task/1219373274369023
Result
Pending. Verified on production right after release: the Indeed link now returns the full posting through the fallback in 3.3 s on Haiku. Volume through this endpoint is still very low, so the before/after is a handful of fetches; the point to measure is that site refusals stop being member-facing failures, and what the fallback costs.
Metrics
| Metric | Baseline (2026-09-09 to 2026-10-09 20:27 UTC) | Target | Latest (date) |
|---|---|---|---|
Production web-fetch calls failing with FETCH_FAILED |
3 of 4 (75%); all 3 www.indeed.com |
Only 404/410 or Claude-also-failed | pending |
Fallbacks that succeeded (fell back to claude_web_fetch) |
n/a (did not exist) | ≈ every 4xx refusal | pending |
Fallbacks that failed (web-fetch fallback failed) |
n/a | 0 | pending |
How to measure
CloudWatch, api-python production, via the qwestly-logs skill
(agent-skills/qwestly-logs/logs-cli in qwestly-workspace). For the window after release, use
--since 2026-10-09T20:27:
S=agent-skills/qwestly-logs
# Outcomes by code (ok / failed)
$S/logs-cli search --app api --since 2026-10-09T20:27 --until <end> --query \
'filter message like /web-fetch (ok|failed)/ | parse message /web-fetch (?<outcome>ok|failed): (code=(?<code>\S+) )?strategy=(?<strategy>\S+)/ | stats count() by outcome, code'
# Fallbacks, successful and failed, by host
$S/logs-cli search --app api --since 2026-10-09T20:27 --until <end> --query \
'filter message like /fell back to claude_web_fetch|fallback failed/ | parse message /host=(?<h>[^ ]+)/ | parse message /web-fetch (?<what>fell back|fallback failed)/ | stats count() as n by what, h'
Caveats: a failure that then falls back successfully still logs one web-fetch failed line (the
first attempt), so count member-facing failures as failed minus fell back. The baseline window
includes manual tries from the internal test page, not just member traffic. Each fallback is a
Claude call (≈8k input / 150 output tokens on Haiku for the Indeed page).
Log
- 2026-10-09: Baseline taken from CloudWatch (4 calls in 30 days, 3
FETCH_FAILED, all Indeed). Fallback shipped to production; Indeed link verified on api.qwestly.com.