luckiest-co 1.0.47 → 1.0.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +5 -0
  5. package/skills/ab-testing/SKILL.md +12 -9
  6. package/skills/ab-testing/evals/evals.json +18 -7
  7. package/skills/ab-testing/references/sample-size-guide.md +2 -2
  8. package/skills/ads/CHANGELOG.md +5 -0
  9. package/skills/ads/SKILL.md +4 -4
  10. package/skills/ai-seo/CHANGELOG.md +7 -0
  11. package/skills/ai-seo/SKILL.md +27 -20
  12. package/skills/analytics/CHANGELOG.md +6 -0
  13. package/skills/analytics/SKILL.md +4 -3
  14. package/skills/analytics/evals/evals.json +27 -0
  15. package/skills/analytics/references/ga4-implementation.md +7 -3
  16. package/skills/analytics/references/gtm-implementation.md +8 -2
  17. package/skills/aso/CHANGELOG.md +5 -0
  18. package/skills/aso/SKILL.md +4 -4
  19. package/skills/aso/evals/evals.json +15 -2
  20. package/skills/aso/references/benchmarks.md +4 -2
  21. package/skills/aso/references/google-play-specs.md +5 -1
  22. package/skills/brainstorm/SKILL.md +59 -16
  23. package/skills/brainstorm/builder-check-prompt.md +23 -0
  24. package/skills/churn-prevention/CHANGELOG.md +5 -0
  25. package/skills/churn-prevention/SKILL.md +3 -3
  26. package/skills/churn-prevention/evals/evals.json +14 -0
  27. package/skills/churn-prevention/references/dunning-playbook.md +6 -1
  28. package/skills/coder-brainstorming/CHANGELOG.md +10 -0
  29. package/skills/coder-brainstorming/SKILL.md +62 -20
  30. package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
  31. package/skills/coder-brainstorming/scripts/server.cjs +1 -1
  32. package/skills/coder-debugging/CHANGELOG.md +4 -0
  33. package/skills/coder-debugging/SKILL.md +5 -5
  34. package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
  35. package/skills/coder-requesting-code-review/SKILL.md +4 -4
  36. package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
  37. package/skills/coder-subagent-driven-development/SKILL.md +3 -3
  38. package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
  39. package/skills/cold-email/CHANGELOG.md +7 -0
  40. package/skills/cold-email/SKILL.md +27 -7
  41. package/skills/cold-email/evals/evals.json +30 -0
  42. package/skills/cold-email/references/benchmarks.md +15 -11
  43. package/skills/cold-email/references/deliverability.md +110 -0
  44. package/skills/cold-email/references/linkedin-outreach.md +91 -0
  45. package/skills/cold-email/references/multichannel-cadence.md +79 -0
  46. package/skills/cold-email/references/outbound-plays.md +88 -0
  47. package/skills/cold-email/references/personalization.md +11 -0
  48. package/skills/cold-email/references/reply-handling.md +65 -0
  49. package/skills/competitor-profiling/CHANGELOG.md +5 -0
  50. package/skills/competitor-profiling/SKILL.md +4 -4
  51. package/skills/content-strategy/CHANGELOG.md +5 -0
  52. package/skills/content-strategy/SKILL.md +5 -3
  53. package/skills/copy-editing/CHANGELOG.md +5 -0
  54. package/skills/copy-editing/SKILL.md +5 -5
  55. package/skills/copy-editing/references/content-refresh.md +33 -0
  56. package/skills/cro/CHANGELOG.md +5 -0
  57. package/skills/cro/SKILL.md +14 -3
  58. package/skills/cro/references/form.md +1 -1
  59. package/skills/directory-submissions/CHANGELOG.md +5 -0
  60. package/skills/directory-submissions/SKILL.md +13 -4
  61. package/skills/directory-submissions/evals/evals.json +15 -1
  62. package/skills/directory-submissions/references/directory-list.md +2 -4
  63. package/skills/emails/CHANGELOG.md +5 -0
  64. package/skills/emails/SKILL.md +14 -4
  65. package/skills/launch/CHANGELOG.md +7 -0
  66. package/skills/launch/SKILL.md +8 -4
  67. package/skills/launch/evals/evals.json +29 -0
  68. package/skills/launch/references/shipped-changes.md +84 -0
  69. package/skills/launch/references/site-launch-qa.md +55 -0
  70. package/skills/lead-magnets/CHANGELOG.md +5 -0
  71. package/skills/lead-magnets/SKILL.md +6 -4
  72. package/skills/lead-magnets/evals/evals.json +18 -0
  73. package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
  74. package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
  75. package/skills/lead-magnets/references/content-to-skill.md +108 -0
  76. package/skills/marketing-plan/CHANGELOG.md +5 -0
  77. package/skills/marketing-plan/SKILL.md +3 -3
  78. package/skills/marketing-plan/evals/evals.json +13 -0
  79. package/skills/marketing-plan/references/budget-planning.md +26 -17
  80. package/skills/pricing/CHANGELOG.md +5 -0
  81. package/skills/pricing/SKILL.md +4 -4
  82. package/skills/pricing/evals/evals.json +12 -0
  83. package/skills/pricing/references/research-methods.md +8 -4
  84. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  85. package/skills/programmatic-seo/SKILL.md +8 -4
  86. package/skills/programmatic-seo/evals/evals.json +13 -0
  87. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  88. package/skills/prospecting/CHANGELOG.md +7 -0
  89. package/skills/prospecting/SKILL.md +27 -13
  90. package/skills/prospecting/evals/evals.json +41 -0
  91. package/skills/prospecting/references/account-research.md +59 -0
  92. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  93. package/skills/prospecting/references/compliance.md +44 -6
  94. package/skills/prospecting/references/data-sources.md +13 -15
  95. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  96. package/skills/prospecting/references/saas-prospecting.md +1 -1
  97. package/skills/prospecting/references/signal-plays.md +99 -0
  98. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  99. package/skills/referrals/CHANGELOG.md +5 -0
  100. package/skills/referrals/SKILL.md +10 -4
  101. package/skills/referrals/evals/evals.json +99 -0
  102. package/skills/referrals/references/affiliate-operations.md +64 -0
  103. package/skills/referrals/references/affiliate-programs.md +2 -2
  104. package/skills/referrals/references/program-examples.md +14 -6
  105. package/skills/referrals/references/review-platform-rules.md +36 -0
  106. package/skills/referrals/references/review-program-operations.md +62 -0
  107. package/skills/referrals/references/review-programs.md +96 -0
  108. package/skills/revops/CHANGELOG.md +5 -0
  109. package/skills/revops/SKILL.md +10 -6
  110. package/skills/revops/evals/evals.json +13 -0
  111. package/skills/revops/references/automation-playbooks.md +1 -1
  112. package/skills/revops/references/inbound-routing.md +43 -0
  113. package/skills/revops/references/lifecycle-definitions.md +31 -0
  114. package/skills/sales-enablement/CHANGELOG.md +5 -0
  115. package/skills/sales-enablement/SKILL.md +6 -4
  116. package/skills/sales-enablement/evals/evals.json +14 -0
  117. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  118. package/skills/schema/CHANGELOG.md +5 -0
  119. package/skills/schema/SKILL.md +20 -8
  120. package/skills/schema/evals/evals.json +40 -0
  121. package/skills/schema/references/schema-examples.md +9 -15
  122. package/skills/seo-audit/CHANGELOG.md +5 -0
  123. package/skills/seo-audit/SKILL.md +10 -16
  124. package/skills/seo-audit/evals/evals.json +68 -0
  125. package/skills/seo-audit/references/international-seo.md +9 -11
  126. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  127. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  128. package/skills/seo-audit/references/local-seo.md +86 -0
  129. package/skills/seo-audit/references/rankings-push.md +62 -0
  130. package/skills/seo-audit/references/title-tags.md +89 -0
  131. package/skills/signup/CHANGELOG.md +5 -0
  132. package/skills/signup/SKILL.md +7 -3
  133. package/skills/site-architecture/CHANGELOG.md +5 -0
  134. package/skills/site-architecture/SKILL.md +5 -4
  135. package/skills/site-architecture/evals/evals.json +25 -0
  136. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  137. package/skills/social/CHANGELOG.md +5 -0
  138. package/skills/social/SKILL.md +4 -4
  139. package/skills/trends/.skillignore +3 -0
  140. package/skills/trends/CHANGELOG.md +20 -0
  141. package/skills/trends/SKILL.md +12 -8
  142. package/skills/trends/references/agent-mode.md +36 -0
  143. package/skills/trends/references/category-peers.md +38 -0
  144. package/skills/trends/references/comparison.md +227 -0
  145. package/skills/trends/references/competitors.md +29 -0
  146. package/skills/trends/references/discovery.md +90 -0
  147. package/skills/trends/references/engine-contract.md +111 -1889
  148. package/skills/trends/references/followups.md +112 -0
  149. package/skills/trends/references/grok-bot-x.md +55 -0
  150. package/skills/trends/references/hiring-signals.md +19 -0
  151. package/skills/trends/references/library-queue.md +33 -0
  152. package/skills/trends/references/recommendations.md +86 -0
  153. package/skills/trends/references/research-runbook.md +781 -0
  154. package/skills/trends/references/runtime.md +165 -0
  155. package/skills/trends/references/save-html-brief.md +39 -20
  156. package/skills/trends/references/setup-wizard.md +253 -0
  157. package/skills/trends/references/synthesis.md +289 -0
  158. package/skills/trends/scripts/box_chrome_login.py +209 -0
  159. package/skills/trends/scripts/briefing.py +9 -17
  160. package/skills/trends/scripts/last30days.py +3247 -280
  161. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  162. package/skills/trends/scripts/lib/amazon.py +877 -0
  163. package/skills/trends/scripts/lib/arxiv.py +42 -8
  164. package/skills/trends/scripts/lib/backends.py +769 -0
  165. package/skills/trends/scripts/lib/bird_x.py +340 -57
  166. package/skills/trends/scripts/lib/bluesky.py +84 -10
  167. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  168. package/skills/trends/scripts/lib/brightdata.py +291 -0
  169. package/skills/trends/scripts/lib/categories.py +7 -1
  170. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  171. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  172. package/skills/trends/scripts/lib/cluster.py +31 -54
  173. package/skills/trends/scripts/lib/competitors.py +10 -1
  174. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  175. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  176. package/skills/trends/scripts/lib/corpus.py +439 -0
  177. package/skills/trends/scripts/lib/devto.py +313 -0
  178. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  179. package/skills/trends/scripts/lib/doctor.py +2166 -0
  180. package/skills/trends/scripts/lib/dripstack.py +203 -0
  181. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  182. package/skills/trends/scripts/lib/env.py +1092 -138
  183. package/skills/trends/scripts/lib/fanout.py +11 -1
  184. package/skills/trends/scripts/lib/feed.py +61 -0
  185. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  186. package/skills/trends/scripts/lib/freshness.py +566 -0
  187. package/skills/trends/scripts/lib/fusion.py +314 -28
  188. package/skills/trends/scripts/lib/github.py +438 -24
  189. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  190. package/skills/trends/scripts/lib/grounding.py +129 -11
  191. package/skills/trends/scripts/lib/hackernews.py +61 -4
  192. package/skills/trends/scripts/lib/health.py +373 -0
  193. package/skills/trends/scripts/lib/hosted.py +333 -0
  194. package/skills/trends/scripts/lib/html_publish.py +34 -0
  195. package/skills/trends/scripts/lib/html_render.py +169 -3
  196. package/skills/trends/scripts/lib/http.py +1215 -70
  197. package/skills/trends/scripts/lib/instagram.py +132 -9
  198. package/skills/trends/scripts/lib/jobs.py +4 -2
  199. package/skills/trends/scripts/lib/library.py +308 -0
  200. package/skills/trends/scripts/lib/library_index.py +477 -0
  201. package/skills/trends/scripts/lib/linkedin.py +271 -24
  202. package/skills/trends/scripts/lib/log.py +5 -2
  203. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  204. package/skills/trends/scripts/lib/normalize.py +382 -28
  205. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  206. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  207. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  208. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  209. package/skills/trends/scripts/lib/planner.py +396 -53
  210. package/skills/trends/scripts/lib/polymarket.py +285 -23
  211. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  212. package/skills/trends/scripts/lib/private_files.py +21 -0
  213. package/skills/trends/scripts/lib/providers.py +231 -10
  214. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  215. package/skills/trends/scripts/lib/reddit.py +177 -12
  216. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  217. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  218. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  219. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  220. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  221. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  222. package/skills/trends/scripts/lib/registers.py +134 -0
  223. package/skills/trends/scripts/lib/relevance.py +17 -0
  224. package/skills/trends/scripts/lib/render.py +2161 -271
  225. package/skills/trends/scripts/lib/rerank.py +623 -55
  226. package/skills/trends/scripts/lib/resolve.py +50 -1
  227. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  228. package/skills/trends/scripts/lib/schema.py +782 -1
  229. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  230. package/skills/trends/scripts/lib/signals.py +206 -7
  231. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  232. package/skills/trends/scripts/lib/subproc.py +307 -53
  233. package/skills/trends/scripts/lib/techmeme.py +89 -64
  234. package/skills/trends/scripts/lib/telegram.py +317 -0
  235. package/skills/trends/scripts/lib/tiktok.py +13 -9
  236. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  237. package/skills/trends/scripts/lib/transcribe.py +12 -3
  238. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  239. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  240. package/skills/trends/scripts/lib/ui.py +6 -0
  241. package/skills/trends/scripts/lib/usage.py +132 -0
  242. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  243. package/skills/trends/scripts/lib/windows_job.py +138 -0
  244. package/skills/trends/scripts/lib/x_api.py +824 -0
  245. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  246. package/skills/trends/scripts/lib/x_judge.py +292 -0
  247. package/skills/trends/scripts/lib/xai_x.py +26 -3
  248. package/skills/trends/scripts/lib/xquik.py +89 -15
  249. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  250. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  251. package/skills/trends/scripts/setup-keychain.sh +12 -4
  252. package/skills/trends/scripts/setup-pass.sh +3 -0
  253. package/skills/trends/scripts/store.py +444 -50
  254. package/skills/trends/scripts/watchlist.py +38 -11
  255. package/skills/trends/scripts/x_lane_parity.py +107 -0
  256. package/skills/video-studio/CHANGELOG.md +4 -0
  257. package/skills/video-studio/SKILL.md +4 -4
  258. package/skills/video-studio/references/styles/product-flight.md +45 -0
  259. package/skills/video-studio/references/styles.md +3 -1
  260. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  261. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  262. package/skills/video-studio-motion/SKILL.md +8 -5
  263. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  264. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -1,16 +1,24 @@
1
1
  """HTTP utilities for last30days skill (stdlib only)."""
2
2
 
3
3
  import json
4
+ from collections import OrderedDict
5
+ import math
6
+ import os
7
+ import random
4
8
  import re
5
9
  import socket
6
- import sys
7
10
  import threading
8
11
  import time
9
12
  import urllib.error
10
13
  import urllib.request
11
- from typing import Any, Dict, Optional, Union
12
- from urllib.parse import urlencode
14
+ from concurrent.futures import Future
15
+ from contextlib import contextmanager
16
+ from contextvars import ContextVar, copy_context
17
+ from pathlib import Path
18
+ from typing import Any, Callable, Dict, Optional, Union
19
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit, quote
13
20
 
21
+ from . import bounded_get, health, usage
14
22
  from . import log as _log
15
23
 
16
24
  DEFAULT_TIMEOUT = 30
@@ -24,6 +32,54 @@ def log(msg: str):
24
32
  MAX_RETRIES = 5
25
33
  MAX_429_RETRIES = 2
26
34
  RETRY_DELAY = 2.0
35
+
36
+
37
+ # Longest a 429 retry may sleep on any host. Reddit's x-ratelimit-reset can say
38
+ # 540s and GitHub's is an epoch timestamp; neither is worth parking a worker
39
+ # (or the main thread) for. Past this bound the retry is not worth taking, so
40
+ # the caller's own fallback backoff applies and the request fails fast.
41
+ MAX_RETRY_DELAY_SECONDS = 60.0
42
+ # A reset value this large is an absolute epoch timestamp, not delta-seconds.
43
+ _EPOCH_RESET_THRESHOLD = 100_000_000.0
44
+
45
+
46
+ def retry_delay_from_headers(headers, fallback):
47
+ """Seconds to wait after a 429, read from whichever header the host sent.
48
+
49
+ ``Retry-After`` is the standard, but Reddit's keyless search endpoints answer an
50
+ anonymous 429 with ``x-ratelimit-reset`` (seconds until the window rolls) and
51
+ no ``Retry-After`` at all::
52
+
53
+ HTTP/2 429
54
+ x-ratelimit-used: 1
55
+ x-ratelimit-remaining: 0.0
56
+ x-ratelimit-reset: 42
57
+
58
+ Reading only ``Retry-After`` means the caller falls back to exponential
59
+ backoff -- 3s, 5s, 9s -- every one of which is shorter than the ~42s Reddit
60
+ actually requires. Each retry re-429s, the budget drains, and the source is
61
+ reported dead when it was merely early. Honouring the reset header turns a
62
+ guaranteed zero into a result at the cost of one wait.
63
+
64
+ Returns ``fallback`` when neither header is present or parseable.
65
+ """
66
+ if not headers:
67
+ return fallback
68
+ for name in ("Retry-After", "x-ratelimit-reset"):
69
+ raw = headers.get(name)
70
+ if raw is None:
71
+ continue
72
+ try:
73
+ value = float(raw)
74
+ except (TypeError, ValueError):
75
+ continue
76
+ if value >= _EPOCH_RESET_THRESHOLD:
77
+ # GitHub-style absolute reset time.
78
+ value = value - time.time()
79
+ if value > 0:
80
+ return min(value, MAX_RETRY_DELAY_SECONDS)
81
+ return fallback
82
+
27
83
  # DNS resolution failures (gaierror) are transient — typically resolved by a
28
84
  # brief backoff and retry. Use a dedicated minimum attempt count + exponential
29
85
  # delays (1s, 2s, 4s) so callers that pass a small `retries` value still get a
@@ -31,6 +87,511 @@ RETRY_DELAY = 2.0
31
87
  MIN_DNS_RETRIES = 3
32
88
  USER_AGENT = "last30days-skill/3.0 (Assistant Skill)"
33
89
 
90
+ # urllib copies almost all headers across 3xx; strip credentials when origin changes (#1062).
91
+ _CROSS_ORIGIN_AUTH_HEADERS = frozenset(
92
+ {"authorization", "x-api-key", "x-csrf-token", "x-subscription-token"}
93
+ )
94
+
95
+
96
+ def _request_origin(url: str) -> tuple[str, str, int]:
97
+ parts = urlsplit(url)
98
+ scheme = parts.scheme.lower()
99
+ host = (parts.hostname or "").lower()
100
+ if parts.port is not None:
101
+ port = parts.port
102
+ elif scheme == "https":
103
+ port = 443
104
+ elif scheme == "http":
105
+ port = 80
106
+ else:
107
+ port = 0
108
+ return scheme, host, port
109
+
110
+
111
+ class _StripAuthOnCrossOriginRedirect(urllib.request.HTTPRedirectHandler):
112
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
113
+ new = super().redirect_request(req, fp, code, msg, headers, newurl)
114
+ if new is None:
115
+ return None
116
+ if _request_origin(req.full_url) != _request_origin(new.full_url):
117
+ for store in (new.headers, getattr(new, "unredirected_hdrs", None)):
118
+ if not store:
119
+ continue
120
+ for name in list(store):
121
+ if name.lower() in _CROSS_ORIGIN_AUTH_HEADERS:
122
+ del store[name]
123
+ return new
124
+
125
+
126
+ _opener = urllib.request.build_opener(_StripAuthOnCrossOriginRedirect)
127
+ _direct_opener = urllib.request.build_opener(
128
+ urllib.request.ProxyHandler({}), _StripAuthOnCrossOriginRedirect
129
+ )
130
+ _DEFAULT_URLOPEN = urllib.request.urlopen
131
+
132
+
133
+ def _open_request(req, timeout, *, bypass_proxy=False):
134
+ """Honor test patches of urllib.request.urlopen; otherwise use the strip opener."""
135
+ current = urllib.request.urlopen
136
+ if current is not _DEFAULT_URLOPEN:
137
+ return current(req, timeout=timeout)
138
+ opener = _direct_opener if bypass_proxy else _opener
139
+ return opener.open(req, timeout=timeout)
140
+
141
+
142
+ def open_request(req, timeout):
143
+ """Open ``req`` with cross-origin credential stripping (see #1062).
144
+
145
+ Public entry point for modules that build their own ``Request`` and would
146
+ otherwise call ``urllib.request.urlopen`` directly, bypassing the redirect
147
+ handler installed above.
148
+ """
149
+ return _open_request(req, timeout)
150
+
151
+
152
+ _failure_sink: ContextVar[Optional[list["HTTPError"]]] = ContextVar(
153
+ "last30days_http_failure_sink",
154
+ default=None,
155
+ )
156
+ _expected_miss_statuses: ContextVar[frozenset[int]] = ContextVar(
157
+ "last30days_http_expected_miss_statuses",
158
+ default=frozenset(),
159
+ )
160
+
161
+ _FIXTURE_FORMAT = "last30days-http-fixture/v1"
162
+ _FIXTURE_SECRET_KEYS = frozenset(
163
+ {
164
+ "api_key", "apikey", "authorization", "cookie", "key", "secret", "token",
165
+ "password", "passwd", "passphrase", "credential", "bearer", "jwt",
166
+ }
167
+ )
168
+ # Suffixes are matched on the normalized key, where camelCase collapses without
169
+ # a separator ("accessJwt" -> "accessjwt"), so these are bare rather than
170
+ # underscore-prefixed. "key" is deliberately absent: it would redact "monkey".
171
+ _FIXTURE_SECRET_KEY_SUFFIXES = (
172
+ "_api_key", "apikey", "_authorization", "_cookie", "_secret", "_token",
173
+ "password", "passwd", "passphrase", "credential", "jwt",
174
+ )
175
+ _fixture_lock = threading.Lock()
176
+ _fixture_state: Optional[dict[str, Any]] = None
177
+ _NO_FIXTURE = object()
178
+ _fixture_module_capture: ContextVar[bool] = ContextVar(
179
+ "last30days_fixture_module_capture",
180
+ default=False,
181
+ )
182
+
183
+
184
+ def _is_secret_key(value: object) -> bool:
185
+ key = re.sub(r"[^a-z0-9]+", "_", str(value).lower()).strip("_")
186
+ return (
187
+ key in _FIXTURE_SECRET_KEYS
188
+ or key.endswith(_FIXTURE_SECRET_KEY_SUFFIXES)
189
+ )
190
+
191
+
192
+ def _scrub_fixture_value(
193
+ value: Any,
194
+ *,
195
+ key: str = "",
196
+ redactions: frozenset[str] = frozenset(),
197
+ ) -> Any:
198
+ """Remove credentials before a recorded exchange reaches disk."""
199
+ if key and _is_secret_key(key):
200
+ return "<redacted>"
201
+ if isinstance(value, dict):
202
+ return {
203
+ str(child_key): _scrub_fixture_value(
204
+ child_value,
205
+ key=str(child_key),
206
+ redactions=redactions,
207
+ )
208
+ for child_key, child_value in value.items()
209
+ }
210
+ if isinstance(value, list):
211
+ return [_scrub_fixture_value(item, redactions=redactions) for item in value]
212
+ if isinstance(value, str):
213
+ scrubbed = value
214
+ for secret in sorted(redactions, key=len, reverse=True):
215
+ if len(secret) >= 4:
216
+ scrubbed = scrubbed.replace(secret, "<redacted>")
217
+ return scrubbed
218
+ return value
219
+
220
+
221
+ _AUTH_SCHEME_RE = re.compile(r"^(?:bearer|basic|token)\s+(\S+)$", re.IGNORECASE)
222
+
223
+
224
+ def _collect_secret_values(value: Any, *, key: str = "") -> set[str]:
225
+ values: set[str] = set()
226
+ if key and _is_secret_key(key) and value not in (None, ""):
227
+ text = str(value)
228
+ values.add(text)
229
+ # "Bearer <token>": the bare token is what a response body or an
230
+ # adapter error message echoes, so redact it on its own too.
231
+ scheme = _AUTH_SCHEME_RE.match(text.strip())
232
+ if scheme:
233
+ values.add(scheme.group(1))
234
+ return values
235
+ if isinstance(value, dict):
236
+ for child_key, child_value in value.items():
237
+ values.update(_collect_secret_values(child_value, key=str(child_key)))
238
+ elif isinstance(value, list):
239
+ for child in value:
240
+ values.update(_collect_secret_values(child))
241
+ return values
242
+
243
+
244
+ def _fixture_redactions(
245
+ url: str,
246
+ headers: dict[str, str],
247
+ json_data: Optional[Dict[str, Any]],
248
+ ) -> frozenset[str]:
249
+ values: set[str] = set()
250
+ try:
251
+ for key, value in parse_qsl(urlsplit(url).query, keep_blank_values=True):
252
+ if _is_secret_key(key) and value:
253
+ values.add(value)
254
+ except ValueError:
255
+ pass
256
+ values.update(_collect_secret_values(headers))
257
+ values.update(_collect_secret_values(json_data))
258
+ # Session-wide secret values (process env plus resolved config) so a
259
+ # bearer loaded from .env, Keychain, or pass is scrubbed at the HTTP
260
+ # seam even when this request carried it only in a header.
261
+ with _fixture_lock:
262
+ state = _fixture_state
263
+ if state is not None and state.get("redactions"):
264
+ values.update(state["redactions"])
265
+ return frozenset(values)
266
+
267
+
268
+ def config_secret_values(config: Dict[str, Any]) -> frozenset[str]:
269
+ """Secret VALUES from a resolved config, for fixture redaction.
270
+
271
+ Every key the Keychain/pass loader knows (``env.KEYCHAIN_KEYS``) plus any
272
+ secret-named key is a credential regardless of which layer supplied it.
273
+ """
274
+ values: set[str] = set()
275
+ try:
276
+ from . import env as _env
277
+ secret_keys = set(_env.KEYCHAIN_KEYS)
278
+ except Exception: # pragma: no cover - env is always importable
279
+ secret_keys = set()
280
+ for key, value in (config or {}).items():
281
+ if not isinstance(value, str) or len(value) < 4:
282
+ continue
283
+ if "://" in value:
284
+ # An endpoint (e.g. an API base URL) is an address, not a credential.
285
+ continue
286
+ if key in secret_keys or _is_secret_key(key):
287
+ values.add(value)
288
+ return frozenset(values)
289
+
290
+
291
+ def add_fixture_redactions(values) -> None:
292
+ """Register secret values with the active recording session, if any.
293
+
294
+ ``env.get_config`` calls this with the resolved config's secrets so a
295
+ bearer loaded from a file or a credential store is redacted at both the
296
+ HTTP seam and the module seam. A no-op outside a recording session.
297
+ """
298
+ extra = {v for v in values if isinstance(v, str) and len(v) >= 4}
299
+ if not extra:
300
+ return
301
+ with _fixture_lock:
302
+ state = _fixture_state
303
+ if state is None or state["mode"] != "record":
304
+ return
305
+ state["redactions"] = frozenset(state.get("redactions") or frozenset()) | extra
306
+
307
+
308
+ def _scrub_fixture_url(url: str) -> str:
309
+ try:
310
+ parts = urlsplit(url)
311
+ query = urlencode(
312
+ [
313
+ (key, "<redacted>" if _is_secret_key(key) else value)
314
+ for key, value in parse_qsl(parts.query, keep_blank_values=True)
315
+ ]
316
+ )
317
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment))
318
+ except ValueError:
319
+ return url
320
+
321
+
322
+ def _fixture_request(
323
+ method: str,
324
+ url: str,
325
+ json_data: Optional[Dict[str, Any]],
326
+ raw: bool,
327
+ ) -> dict[str, Any]:
328
+ request_data: dict[str, Any] = {
329
+ "method": method.upper(),
330
+ "url": _scrub_fixture_url(url),
331
+ "raw": bool(raw),
332
+ }
333
+ if json_data is not None:
334
+ request_data["json"] = _scrub_fixture_value(json_data)
335
+ return request_data
336
+
337
+
338
+ def _fixture_key(request_data: dict[str, Any]) -> str:
339
+ return json.dumps(request_data, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
340
+
341
+
342
+ @contextmanager
343
+ def recording_requests(path: str | Path):
344
+ """Record scrubbed HTTP exchanges to ``path`` for offline eval replay.
345
+
346
+ This process-global session is deliberate: source requests run in worker
347
+ threads, so a ContextVar would not observe the complete pipeline fan-out.
348
+ Nested or concurrent recording/replay sessions are rejected.
349
+ """
350
+ global _fixture_state
351
+ target = Path(path).expanduser()
352
+ if target.suffix.lower() != ".json":
353
+ target = target / "http.json"
354
+ with _fixture_lock:
355
+ if _fixture_state is not None:
356
+ raise RuntimeError("An HTTP fixture session is already active")
357
+ _fixture_state = {
358
+ "mode": "record",
359
+ "path": target,
360
+ "exchanges": [],
361
+ "source_exchanges": [],
362
+ # Secret VALUES from the environment, so module-seam recordings
363
+ # scrub tokens echoed inside normal string fields (adapter error
364
+ # messages, parsed item text), not just secret-named keys. The
365
+ # resolved config's secrets join via add_fixture_redactions once
366
+ # env.get_config runs inside the session.
367
+ "redactions": frozenset(
368
+ value
369
+ for key, value in os.environ.items()
370
+ if _is_secret_key(key) and isinstance(value, str) and len(value) >= 4
371
+ ),
372
+ }
373
+ completed = False
374
+ try:
375
+ yield target
376
+ completed = True
377
+ finally:
378
+ with _fixture_lock:
379
+ state = _fixture_state
380
+ _fixture_state = None
381
+ if state is not None and completed:
382
+ target.parent.mkdir(parents=True, exist_ok=True)
383
+ payload = {
384
+ "format": _FIXTURE_FORMAT,
385
+ "exchanges": state["exchanges"],
386
+ "source_exchanges": state["source_exchanges"],
387
+ }
388
+ # A recorded exchange is credential-adjacent by construction:
389
+ # redaction is key-name driven, so an unrecognized key name leaves
390
+ # a real value on disk. Create the temp file 0600 at open time
391
+ # rather than chmod-ing after the write, or the credentials sit in
392
+ # a world-readable file for the length of the write (the parent
393
+ # directory is caller-supplied and not guaranteed private).
394
+ # Mirrors last30days.save_output. Unlink first so a stale or
395
+ # pre-planted temp file cannot be reused with its own wider mode --
396
+ # O_CREAT does not alter the mode of an existing file.
397
+ temporary = target.with_name(f".{target.name}.tmp")
398
+ temporary.unlink(missing_ok=True)
399
+ fd = os.open(
400
+ temporary,
401
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY,
402
+ 0o600,
403
+ )
404
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
405
+ handle.write(
406
+ json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
407
+ )
408
+ temporary.replace(target)
409
+
410
+
411
+ @contextmanager
412
+ def fixture_module_capture(enabled: bool):
413
+ """Suppress nested HTTP recording when a whole adapter result is captured."""
414
+ token = _fixture_module_capture.set(enabled)
415
+ try:
416
+ yield
417
+ finally:
418
+ _fixture_module_capture.reset(token)
419
+
420
+
421
+ @contextmanager
422
+ def replaying_requests(path: str | Path):
423
+ """Replay recorded exchanges and fail closed on any unrecorded request."""
424
+ global _fixture_state
425
+ target = Path(path).expanduser()
426
+ if target.is_dir():
427
+ target = target / "http.json"
428
+ payload = json.loads(target.read_text(encoding="utf-8"))
429
+ if payload.get("format") != _FIXTURE_FORMAT:
430
+ raise ValueError(f"Unsupported HTTP fixture format in {target}")
431
+ queues: dict[str, list[dict[str, Any]]] = {}
432
+ for exchange in payload.get("exchanges") or []:
433
+ queues.setdefault(_fixture_key(exchange["request"]), []).append(exchange["response"])
434
+ source_queues: dict[str, list[Any]] = {}
435
+ for exchange in payload.get("source_exchanges") or []:
436
+ source_queues.setdefault(_fixture_key(exchange["request"]), []).append(exchange)
437
+ with _fixture_lock:
438
+ if _fixture_state is not None:
439
+ raise RuntimeError("An HTTP fixture session is already active")
440
+ _fixture_state = {
441
+ "mode": "replay",
442
+ "path": target,
443
+ "queues": queues,
444
+ "source_queues": source_queues,
445
+ }
446
+ try:
447
+ yield target
448
+ with _fixture_lock:
449
+ unused = sum(len(values) for values in queues.values()) + sum(
450
+ len(values) for values in source_queues.values()
451
+ )
452
+ if unused:
453
+ raise AssertionError(f"HTTP fixture replay left {unused} unused exchange(s): {target}")
454
+ finally:
455
+ with _fixture_lock:
456
+ _fixture_state = None
457
+
458
+
459
+ def _fixture_replay(request_data: dict[str, Any]) -> Any:
460
+ with _fixture_lock:
461
+ state = _fixture_state
462
+ if state is None or state["mode"] != "replay":
463
+ return _NO_FIXTURE
464
+ queue = state["queues"].get(_fixture_key(request_data))
465
+ if not queue:
466
+ raise AssertionError(
467
+ "Unrecorded HTTP request during fixture replay: "
468
+ f"{request_data['method']} {request_data['url']}"
469
+ )
470
+ response = queue.pop(0)
471
+ if response.get("error"):
472
+ error = response["error"]
473
+ recorded_error = HTTPError(
474
+ str(error.get("message") or "Recorded HTTP error"),
475
+ status_code=error.get("status_code"),
476
+ body=error.get("body"),
477
+ outcome_state=error.get("outcome_state"),
478
+ )
479
+ _raise(recorded_error)
480
+ return response.get("value")
481
+
482
+
483
+ def _fixture_record(
484
+ request_data: dict[str, Any],
485
+ *,
486
+ value: Any = None,
487
+ error: Optional["HTTPError"] = None,
488
+ redactions: frozenset[str] = frozenset(),
489
+ ) -> None:
490
+ if _fixture_module_capture.get():
491
+ return
492
+ with _fixture_lock:
493
+ state = _fixture_state
494
+ if state is None or state["mode"] != "record":
495
+ return
496
+ # Union the session's env-derived secret VALUES in, so a credential
497
+ # echoed back inside an ordinary response field is scrubbed on this
498
+ # path too, not only on the source-record path. Response scrubbing does
499
+ # not feed _fixture_key, so this cannot make a replay key
500
+ # machine-dependent.
501
+ redactions = redactions | (state.get("redactions") or frozenset())
502
+ response: dict[str, Any]
503
+ if error is None:
504
+ response = {"value": _scrub_fixture_value(value, redactions=redactions)}
505
+ else:
506
+ response = {
507
+ "error": _scrub_fixture_value(
508
+ {
509
+ "message": str(error),
510
+ "status_code": error.status_code,
511
+ "body": error.body,
512
+ "outcome_state": error.outcome_state,
513
+ },
514
+ redactions=redactions,
515
+ )
516
+ }
517
+ state["exchanges"].append({"request": request_data, "response": response})
518
+
519
+
520
+ def fixture_source_replay(request_data: dict[str, Any]) -> tuple[bool, Any]:
521
+ """Return a recorded CLI-backed source result when replay is active."""
522
+ scrubbed = _scrub_fixture_value(request_data)
523
+ with _fixture_lock:
524
+ state = _fixture_state
525
+ if state is None or state["mode"] != "replay":
526
+ return False, None
527
+ queue = state["source_queues"].get(_fixture_key(scrubbed))
528
+ if not queue:
529
+ raise AssertionError(
530
+ "Unrecorded CLI-backed source request during fixture replay: "
531
+ f"{request_data.get('source', 'unknown')}"
532
+ )
533
+ exchange = queue.pop(0)
534
+ if exchange.get("type") == "error":
535
+ error = exchange.get("error") or {}
536
+ raise RecordedSourceError(
537
+ str(error.get("message") or "Recorded source error"),
538
+ exception_type=str(error.get("exception_type") or "Exception"),
539
+ outcome_state=error.get("outcome_state"),
540
+ )
541
+ return True, exchange.get("value")
542
+
543
+
544
+ def fixture_source_record(request_data: dict[str, Any], value: Any) -> None:
545
+ """Record the parsed output of a source adapter that bypasses http.py."""
546
+ with _fixture_lock:
547
+ state = _fixture_state
548
+ if state is None or state["mode"] != "record":
549
+ return
550
+ session_redactions = state.get("redactions") or frozenset()
551
+ state["source_exchanges"].append(
552
+ {
553
+ "request": _scrub_fixture_value(request_data, redactions=session_redactions),
554
+ "value": _scrub_fixture_value(value, redactions=session_redactions),
555
+ }
556
+ )
557
+
558
+
559
+ def fixture_source_record_error(request_data: dict[str, Any], error: Exception) -> None:
560
+ """Record a replayable failure from a source adapter that bypasses http.py."""
561
+ with _fixture_lock:
562
+ state = _fixture_state
563
+ if state is None or state["mode"] != "record":
564
+ return
565
+ session_redactions = state.get("redactions") or frozenset()
566
+ state["source_exchanges"].append(
567
+ {
568
+ "request": _scrub_fixture_value(request_data, redactions=session_redactions),
569
+ "type": "error",
570
+ "error": _scrub_fixture_value(
571
+ {
572
+ "exception_type": type(error).__name__,
573
+ "message": str(error),
574
+ "outcome_state": getattr(error, "outcome_state", None),
575
+ }
576
+ , redactions=session_redactions),
577
+ }
578
+ )
579
+
580
+
581
+ class RecordedSourceError(RuntimeError):
582
+ """Failure restored from a recorded module-backed source exchange."""
583
+
584
+ def __init__(
585
+ self,
586
+ message: str,
587
+ *,
588
+ exception_type: str,
589
+ outcome_state: Optional[str] = None,
590
+ ):
591
+ super().__init__(message)
592
+ self.exception_type = exception_type
593
+ self.outcome_state = outcome_state
594
+
34
595
 
35
596
  def _is_dns_failure(err: urllib.error.URLError) -> bool:
36
597
  """Return True if a URLError was caused by DNS resolution (gaierror)."""
@@ -39,10 +600,172 @@ def _is_dns_failure(err: urllib.error.URLError) -> bool:
39
600
 
40
601
  class HTTPError(Exception):
41
602
  """HTTP request error with status code."""
42
- def __init__(self, message: str, status_code: Optional[int] = None, body: Optional[str] = None):
603
+ def __init__(
604
+ self,
605
+ message: str,
606
+ status_code: Optional[int] = None,
607
+ body: Optional[str] = None,
608
+ outcome_state: Optional[str] = None,
609
+ ):
43
610
  super().__init__(message)
44
611
  self.status_code = status_code
45
612
  self.body = body
613
+ self.outcome_state = outcome_state or classify_failure(
614
+ status_code=status_code,
615
+ message=message,
616
+ )
617
+
618
+
619
+ class DeadlineExceeded(HTTPError):
620
+ """The caller's shared wall deadline expired across request retries."""
621
+
622
+ def __init__(self):
623
+ super().__init__(
624
+ "Request deadline exceeded",
625
+ outcome_state=health.TIMEOUT,
626
+ )
627
+
628
+
629
+ @contextmanager
630
+ def capture_failures():
631
+ """Capture terminal request failures in the current retrieval context.
632
+
633
+ Source modules historically catch ``HTTPError`` and return an empty result.
634
+ The context-local sink lets the pipeline retain that failure without shared
635
+ mutable state across its worker threads.
636
+ """
637
+ failures: list[HTTPError] = []
638
+ token = _failure_sink.set(failures)
639
+ try:
640
+ yield failures
641
+ finally:
642
+ _failure_sink.reset(token)
643
+
644
+
645
+ @contextmanager
646
+ def tee_failures():
647
+ """Observe failures locally WITHOUT hiding them from the enclosing sink.
648
+
649
+ ``capture_failures()`` *replaces* the context-local sink, so nesting it
650
+ inside a retrieval context swallows the very failure the pipeline needs.
651
+ This yields a local list and forwards its contents to the parent sink on
652
+ exit, so a swallow site (``get_text`` returns None and drops the status)
653
+ can recover what it lost while the pipeline still sees the failure.
654
+ """
655
+ parent = _failure_sink.get()
656
+ local: list[HTTPError] = []
657
+ token = _failure_sink.set(local)
658
+ try:
659
+ yield local
660
+ finally:
661
+ _failure_sink.reset(token)
662
+ if parent is not None:
663
+ parent.extend(local)
664
+
665
+
666
+ @contextmanager
667
+ def expected_misses(*status_codes: int):
668
+ """Exclude adapter-declared probe misses from captured run failures."""
669
+ token = _expected_miss_statuses.set(
670
+ _expected_miss_statuses.get().union(status_codes)
671
+ )
672
+ try:
673
+ yield
674
+ finally:
675
+ _expected_miss_statuses.reset(token)
676
+
677
+
678
+ def submit_with_context(executor, func, /, *args, **kwargs) -> Future:
679
+ """Submit a worker with the caller's failure-capture context."""
680
+ context = copy_context()
681
+ return executor.submit(context.run, func, *args, **kwargs)
682
+
683
+
684
+ def _record_failure(error: HTTPError) -> None:
685
+ if error.status_code in _expected_miss_statuses.get():
686
+ return
687
+ sink = _failure_sink.get()
688
+ if sink is not None:
689
+ sink.append(error)
690
+
691
+
692
+ def _raise(error: HTTPError) -> None:
693
+ _record_failure(error)
694
+ raise error
695
+
696
+
697
+ def classify_failure(*, status_code: Optional[int] = None, message: str = "") -> str:
698
+ """Map a request failure to the doctor-aligned per-run vocabulary."""
699
+ text = message.lower()
700
+ if status_code == 429 or any(
701
+ marker in text for marker in ("http 429", "status 429", "rate limit", "too many requests")
702
+ ):
703
+ return health.RATE_LIMITED
704
+ # Credit exhaustion is checked before the auth branch: a 402 (or a body
705
+ # saying the account has no credits) asks the user to top up, not to
706
+ # re-authenticate. Markers stay narrow on purpose: the bare word "credits"
707
+ # is not one ("10,000 free credits" is onboarding copy, not a failure).
708
+ if status_code == 402 or any(
709
+ marker in text
710
+ for marker in (
711
+ "http 402",
712
+ "status 402",
713
+ "payment required",
714
+ "insufficient credits",
715
+ "does not have any credits",
716
+ "out of credits",
717
+ )
718
+ ):
719
+ return health.PAYMENT_REQUIRED
720
+ if status_code in (401, 403) or any(
721
+ marker in text
722
+ for marker in (
723
+ "http 401",
724
+ "http 403",
725
+ "status 401",
726
+ "status 403",
727
+ "unauthorized",
728
+ "forbidden",
729
+ "authentication failed",
730
+ "expired token",
731
+ "not signed in",
732
+ "not logged in",
733
+ "invalid_grant",
734
+ "refresh token",
735
+ "session expired",
736
+ "grok session expired",
737
+ )
738
+ ):
739
+ return health.AUTH_FAILED
740
+ if status_code == 408 or "timed out" in text or "timeout" in text:
741
+ return health.TIMEOUT
742
+ if any(
743
+ marker in text
744
+ for marker in (
745
+ "invalid json",
746
+ "json decode",
747
+ "schema",
748
+ "interstitial",
749
+ "non-json",
750
+ )
751
+ ):
752
+ return health.SCHEMA_DRIFT
753
+ if any(
754
+ marker in text
755
+ for marker in (
756
+ "url error",
757
+ "connection error",
758
+ "connection refused",
759
+ "connection reset",
760
+ "name or service not known",
761
+ "temporary failure in name resolution",
762
+ "nodename nor servname",
763
+ "dns",
764
+ "network is unreachable",
765
+ )
766
+ ):
767
+ return health.UNREACHABLE
768
+ return health.ERROR
46
769
 
47
770
 
48
771
  def request(
@@ -51,10 +774,15 @@ def request(
51
774
  headers: Optional[Dict[str, str]] = None,
52
775
  json_data: Optional[Dict[str, Any]] = None,
53
776
  params: Optional[Dict[str, Any]] = None,
54
- timeout: int = DEFAULT_TIMEOUT,
777
+ timeout: float = DEFAULT_TIMEOUT,
55
778
  retries: int = MAX_RETRIES,
56
779
  max_429_retries: int = MAX_429_RETRIES,
57
780
  raw: bool = False,
781
+ deadline_monotonic: float | None = None,
782
+ cancel: threading.Event | None = None,
783
+ owned_get: bool = False,
784
+ on_retry: Callable[[], None] | None = None,
785
+ bypass_proxy: bool = False,
58
786
  ) -> Union[Dict[str, Any], str]:
59
787
  """Make an HTTP request and return JSON response.
60
788
 
@@ -69,6 +797,12 @@ def request(
69
797
  retries: Number of retries on failure
70
798
  max_429_retries: Maximum 429 retries before giving up (separate cap)
71
799
  raw: If True, return raw response text instead of parsed JSON
800
+ deadline_monotonic: Optional absolute monotonic deadline shared by all
801
+ attempts and retry delays.
802
+ owned_get: Run GET transport in a child that is killed and reaped at
803
+ its deadline. Requires deadline_monotonic; other methods are refused.
804
+ on_retry: Called before each transport attempt after the first.
805
+ bypass_proxy: Use a direct connection for this request and its redirects.
72
806
 
73
807
  Returns:
74
808
  Parsed JSON response as dict, or raw text string if raw=True.
@@ -76,6 +810,12 @@ def request(
76
810
  Raises:
77
811
  HTTPError: On request failure
78
812
  """
813
+ if owned_get and (method != "GET" or json_data is not None):
814
+ raise ValueError("owned transport requires a bodyless GET")
815
+ if owned_get and deadline_monotonic is None:
816
+ raise ValueError("owned GET requires an operation deadline")
817
+ if owned_get and bypass_proxy:
818
+ raise ValueError("owned GET does not support proxy bypass")
79
819
  headers = headers or {}
80
820
  headers.setdefault("User-Agent", USER_AGENT)
81
821
 
@@ -84,6 +824,25 @@ def request(
84
824
  if filtered:
85
825
  separator = "&" if ("?" in url) else "?"
86
826
  url = f"{url}{separator}{urlencode(filtered)}"
827
+ # Encode any non-ASCII characters to prevent UnicodeEncodeError from
828
+ # http.client.HTTPConnection.putrequest (which uses latin-1 internally).
829
+ # Only encode path, query, and fragment — not the hostname (netloc), which
830
+ # needs IDNA encoding instead of percent-encoding for non-ASCII domains.
831
+ parts = urlsplit(url)
832
+ safe = '/:@!$&\'()*+,;=-._~%?#[]=+'
833
+ url = urlunsplit((
834
+ parts.scheme,
835
+ parts.netloc,
836
+ quote(parts.path, safe=safe),
837
+ quote(parts.query, safe=safe),
838
+ quote(parts.fragment, safe=safe),
839
+ ))
840
+
841
+ fixture_request = _fixture_request(method, url, json_data, raw)
842
+ fixture_redactions = _fixture_redactions(url, headers, json_data)
843
+ replayed = _fixture_replay(fixture_request)
844
+ if replayed is not _NO_FIXTURE:
845
+ return replayed
87
846
 
88
847
  data = None
89
848
  if json_data is not None:
@@ -92,8 +851,11 @@ def request(
92
851
 
93
852
  req = urllib.request.Request(url, data=data, headers=headers, method=method)
94
853
 
854
+ def log_request(message: str) -> None:
855
+ log(_scrub_fixture_value(message, redactions=fixture_redactions))
856
+
95
857
  safe_url = re.sub(r'([?&])(key|api_key|token|secret)=[^&]*', r'\1\2=***', url)
96
- log(f"{method} {safe_url}")
858
+ log_request(f"{method} {safe_url}")
97
859
 
98
860
  last_error = None
99
861
  rate_limit_count = 0
@@ -103,61 +865,197 @@ def request(
103
865
  effective_retries = retries
104
866
  dns_attempts = 0
105
867
  attempt = 0
106
- while attempt < effective_retries:
868
+
869
+ def raise_recorded(error: HTTPError) -> None:
870
+ _fixture_record(fixture_request, error=error, redactions=fixture_redactions)
871
+ _raise(error)
872
+
873
+ def deadline_error() -> HTTPError:
874
+ return DeadlineExceeded()
875
+
876
+ def sleep_before_retry(delay: float) -> bool:
877
+ """Sleep only when the full delay fits inside the caller's deadline."""
878
+ nonlocal last_error
879
+ if deadline_monotonic is not None:
880
+ remaining = deadline_monotonic - time.monotonic()
881
+ if remaining <= 0 or delay >= remaining:
882
+ last_error = deadline_error()
883
+ return False
884
+ if cancel is not None:
885
+ if cancel.wait(delay):
886
+ last_error = deadline_error()
887
+ return False
888
+ else:
889
+ time.sleep(delay)
890
+ return True
891
+
892
+ def open_and_read(
893
+ request_timeout: float,
894
+ ) -> tuple[int, str | None, urllib.error.HTTPError | None]:
107
895
  try:
108
- with urllib.request.urlopen(req, timeout=timeout) as response:
109
- body = response.read().decode('utf-8')
110
- log(f"Response: {response.status} ({len(body)} bytes)")
111
- if raw:
112
- return body
113
- return json.loads(body) if body else {}
114
- except urllib.error.HTTPError as e:
896
+ transport = (
897
+ _open_request(req, request_timeout, bypass_proxy=True)
898
+ if bypass_proxy else _open_request(req, request_timeout)
899
+ )
900
+ with transport as response:
901
+ return response.status, response.read().decode('utf-8'), None
902
+ except urllib.error.HTTPError as error:
903
+ # Error bodies can stall just like successful bodies. Read both
904
+ # inside the same deadline-protected worker before classification.
115
905
  body = None
116
906
  try:
117
- body = e.read().decode('utf-8')
907
+ body = error.read().decode('utf-8')
118
908
  except (OSError, UnicodeDecodeError):
119
909
  pass
120
- log(f"HTTP Error {e.code}: {e.reason}")
910
+ return error.code, body, error
911
+
912
+ def open_and_read_before_deadline(
913
+ request_timeout: float,
914
+ ) -> tuple[int, str | None, urllib.error.HTTPError | None]:
915
+ """Stop waiting at the wall deadline, even during DNS or body reads."""
916
+ if owned_get:
917
+ try:
918
+ status, body, error = bounded_get.get(
919
+ req, timeout=request_timeout,
920
+ deadline_monotonic=deadline_monotonic, cancel=cancel,
921
+ )
922
+ except bounded_get.GetLaunchError:
923
+ usage.cancel(charge)
924
+ raise
925
+ except bounded_get.GetTimeout as exc:
926
+ if not exc.started:
927
+ usage.cancel(charge)
928
+ raise deadline_error() from exc
929
+ try:
930
+ decoded = body.decode("utf-8") if body is not None else None
931
+ except UnicodeDecodeError:
932
+ if error is None:
933
+ raise
934
+ decoded = None
935
+ return status, decoded, error
936
+ if deadline_monotonic is None and cancel is None:
937
+ return open_and_read(request_timeout)
938
+ wait_deadline = deadline_monotonic or (time.monotonic() + request_timeout)
939
+ remaining = wait_deadline - time.monotonic()
940
+ if remaining <= 0:
941
+ raise deadline_error()
942
+ future: Future = Future()
943
+
944
+ def worker() -> None:
945
+ try:
946
+ if cancel is not None and cancel.is_set():
947
+ raise deadline_error()
948
+ future.set_result(open_and_read(request_timeout))
949
+ except BaseException as exc:
950
+ future.set_exception(exc)
951
+
952
+ threading.Thread(target=worker, daemon=True).start()
953
+ if cancel is None:
954
+ try:
955
+ return future.result(timeout=remaining)
956
+ except TimeoutError as exc:
957
+ if future.done():
958
+ return future.result()
959
+ raise deadline_error() from exc
960
+ while True:
961
+ if cancel.is_set():
962
+ raise deadline_error()
963
+ remaining = wait_deadline - time.monotonic()
964
+ if remaining <= 0:
965
+ raise deadline_error()
966
+ try:
967
+ return future.result(timeout=min(remaining, 0.1))
968
+ except TimeoutError:
969
+ # Preserve worker-side socket failures for transport classification.
970
+ if future.done():
971
+ return future.result()
972
+
973
+ while attempt < effective_retries:
974
+ if cancel is not None and cancel.is_set():
975
+ last_error = deadline_error()
976
+ break
977
+ request_timeout = timeout
978
+ if deadline_monotonic is not None:
979
+ remaining = deadline_monotonic - time.monotonic()
980
+ if remaining <= 0:
981
+ last_error = deadline_error()
982
+ break
983
+ request_timeout = min(timeout, remaining)
984
+ try:
985
+ if attempt > 0 and on_retry is not None:
986
+ on_retry()
987
+ charge = usage.begin_http(url, method)
988
+ response_status, body, response_error = open_and_read_before_deadline(request_timeout)
989
+ if body:
990
+ try:
991
+ usage.finish(charge, json.loads(body))
992
+ except (ValueError, TypeError):
993
+ pass
994
+ if (
995
+ deadline_monotonic is not None
996
+ and time.monotonic() >= deadline_monotonic
997
+ ):
998
+ raise_recorded(deadline_error())
999
+ if response_error is not None:
1000
+ raise response_error
1001
+ body = body or ""
1002
+ log_request(f"Response: {response_status} ({len(body)} bytes)")
1003
+ if raw:
1004
+ _fixture_record(fixture_request, value=body, redactions=fixture_redactions)
1005
+ return body
1006
+ parsed = json.loads(body) if body else {}
1007
+ _fixture_record(fixture_request, value=parsed, redactions=fixture_redactions)
1008
+ return parsed
1009
+ except DeadlineExceeded as exc:
1010
+ raise_recorded(exc)
1011
+ except urllib.error.HTTPError as e:
1012
+ log_request(f"HTTP Error {e.code}: {e.reason}")
121
1013
  if body:
122
- snippet = " ".join(body.split())
123
- log(f"Error body: {snippet[:200]}")
1014
+ snippet = _scrub_fixture_value(" ".join(body.split()), redactions=fixture_redactions)
1015
+ log_request(f"Error body: {snippet[:200]}")
124
1016
  last_error = HTTPError(f"HTTP {e.code}: {e.reason}", e.code, body)
125
1017
 
126
1018
  # Don't retry client errors (4xx) except rate limits
127
1019
  if 400 <= e.code < 500 and e.code != 429:
128
- raise last_error
1020
+ raise_recorded(last_error)
129
1021
 
130
1022
  # Cap 429 retries separately to avoid wasting latency
131
1023
  if e.code == 429:
132
1024
  rate_limit_count += 1
133
1025
  if rate_limit_count >= max_429_retries:
134
- raise last_error
1026
+ raise_recorded(last_error)
135
1027
 
136
1028
  # HTTP errors respect the caller's original `retries`; only DNS
137
1029
  # failures get the widened `effective_retries` budget.
138
1030
  if attempt < retries - 1:
139
1031
  if e.code == 429:
140
- # Respect Retry-After header, fall back to exponential backoff
141
- retry_after = e.headers.get("Retry-After") if hasattr(e, 'headers') else None
142
- if retry_after:
143
- try:
144
- delay = float(retry_after)
145
- except ValueError:
146
- delay = RETRY_DELAY * (2 ** attempt) + 1
147
- else:
148
- delay = RETRY_DELAY * (2 ** attempt) + 1 # 3s, 5s, 9s...
149
- log(f"Rate limited (429). Waiting {delay:.1f}s before retry {attempt + 2}/{retries}")
1032
+ # Respect Retry-After or x-ratelimit-reset (Reddit sends the
1033
+ # latter), falling back to exponential backoff: 3s, 5s, 9s...
1034
+ delay = retry_delay_from_headers(
1035
+ getattr(e, "headers", None),
1036
+ RETRY_DELAY * (2 ** attempt) + 1,
1037
+ )
1038
+ log_request(f"Rate limited (429). Waiting {delay:.1f}s before retry {attempt + 2}/{retries}")
150
1039
  else:
151
1040
  delay = RETRY_DELAY * (2 ** attempt)
152
- time.sleep(delay)
1041
+ if not sleep_before_retry(delay):
1042
+ break
153
1043
  else:
154
1044
  # Caller's original retry budget exhausted; an earlier DNS
155
1045
  # failure may have widened `effective_retries`, but that
156
1046
  # widening is DNS-only — don't grant extra HTTP attempts.
157
1047
  break
158
1048
  except urllib.error.URLError as e:
159
- log(f"URL Error: {e.reason}")
160
- last_error = HTTPError(f"URL Error: {e.reason}")
1049
+ log_request(f"URL Error: {e.reason}")
1050
+ reason = getattr(e, "reason", None)
1051
+ # urllib commonly wraps socket.timeout (an alias of TimeoutError
1052
+ # since 3.10) in URLError; classify those as timeouts, not
1053
+ # unreachable hosts, so the recovery guidance is right.
1054
+ wrapped_timeout = isinstance(reason, TimeoutError) or "timed out" in str(reason).lower()
1055
+ last_error = HTTPError(
1056
+ f"URL Error: {e.reason}",
1057
+ outcome_state=health.TIMEOUT if wrapped_timeout else health.UNREACHABLE,
1058
+ )
161
1059
  if _is_dns_failure(e):
162
1060
  # DNS resolution failures are transient; expand the retry budget
163
1061
  # to MIN_DNS_RETRIES if the caller passed fewer, and use
@@ -166,38 +1064,48 @@ def request(
166
1064
  # causes don't bypass the regular retry budget.
167
1065
  dns_attempts += 1
168
1066
  if effective_retries < MIN_DNS_RETRIES:
169
- log(
1067
+ log_request(
170
1068
  f"DNS resolution failed; expanding retry budget from "
171
1069
  f"{effective_retries} to {MIN_DNS_RETRIES}"
172
1070
  )
173
1071
  effective_retries = MIN_DNS_RETRIES
174
1072
  if attempt < effective_retries - 1:
175
1073
  delay = 2 ** (dns_attempts - 1) # 1s, 2s, 4s, 8s, ...
176
- log(
1074
+ log_request(
177
1075
  f"DNS resolution failure (attempt {dns_attempts}); "
178
1076
  f"retrying in {delay:.1f}s"
179
1077
  )
180
- time.sleep(delay)
1078
+ if not sleep_before_retry(delay):
1079
+ break
181
1080
  elif attempt < retries - 1:
182
1081
  # Non-DNS URLError (e.g. ConnectionRefused) respects the
183
1082
  # caller's original retry budget, not the DNS-widened bound.
184
- time.sleep(RETRY_DELAY * (attempt + 1))
1083
+ if not sleep_before_retry(RETRY_DELAY * (attempt + 1)):
1084
+ break
185
1085
  else:
186
1086
  # Caller's original retry budget exhausted; an earlier DNS
187
1087
  # failure widening `effective_retries` does not carry over
188
1088
  # to non-DNS error paths.
189
1089
  break
190
1090
  except json.JSONDecodeError as e:
191
- log(f"JSON decode error: {e}")
192
- last_error = HTTPError(f"Invalid JSON response: {e}")
193
- raise last_error
1091
+ log_request(f"JSON decode error: {e}")
1092
+ last_error = HTTPError(
1093
+ f"Invalid JSON response: {e}",
1094
+ outcome_state=health.SCHEMA_DRIFT,
1095
+ )
1096
+ raise_recorded(last_error)
194
1097
  except (OSError, TimeoutError, ConnectionResetError) as e:
195
1098
  # Handle socket-level errors (connection reset, timeout, etc.)
196
- log(f"Connection error: {type(e).__name__}: {e}")
197
- last_error = HTTPError(f"Connection error: {type(e).__name__}: {e}")
1099
+ log_request(f"Connection error: {type(e).__name__}: {e}")
1100
+ state = health.TIMEOUT if isinstance(e, TimeoutError) else health.UNREACHABLE
1101
+ last_error = HTTPError(
1102
+ f"Connection error: {type(e).__name__}: {e}",
1103
+ outcome_state=state,
1104
+ )
198
1105
  if attempt < retries - 1:
199
1106
  # Socket errors respect the caller's original retry budget.
200
- time.sleep(RETRY_DELAY * (attempt + 1))
1107
+ if not sleep_before_retry(RETRY_DELAY * (attempt + 1)):
1108
+ break
201
1109
  else:
202
1110
  # Original budget exhausted; DNS widening doesn't apply here.
203
1111
  break
@@ -205,8 +1113,9 @@ def request(
205
1113
  attempt += 1
206
1114
 
207
1115
  if last_error:
208
- raise last_error
209
- raise HTTPError("Request failed with no error details")
1116
+ raise_recorded(last_error)
1117
+ error = HTTPError("Request failed with no error details")
1118
+ raise_recorded(error)
210
1119
 
211
1120
 
212
1121
  def get(url: str, headers: Optional[Dict[str, str]] = None, **kwargs) -> Dict[str, Any]:
@@ -240,7 +1149,7 @@ def get_text(
240
1149
  ) -> Optional[str]:
241
1150
  """Fetch a URL and return decoded text, or None on any failure.
242
1151
 
243
- Keyless helper for Reddit RSS and shreddit HTML endpoints — the free path
1152
+ Keyless helper for Reddit site search and shreddit HTML endpoints, the free path
244
1153
  that replaced the now-403 ``.json`` endpoints. Sends a browser User-Agent
245
1154
  and never raises: returns None on HTTP error, network failure, or timeout
246
1155
  so tiered callers can fall through to the next source.
@@ -249,7 +1158,7 @@ def get_text(
249
1158
  url: Request URL
250
1159
  timeout: HTTP timeout per attempt in seconds
251
1160
  retries: Number of retries on failure (kept low — these tiers fail fast)
252
- accept: Accept header value (e.g. "application/atom+xml", "text/html")
1161
+ accept: Accept header value (e.g. "text/html")
253
1162
  headers: Optional extra headers merged over the defaults
254
1163
 
255
1164
  Returns:
@@ -293,28 +1202,162 @@ class RateLimiter:
293
1202
  self._tokens = float(self.capacity)
294
1203
  self._last = time.monotonic()
295
1204
  self._lock = threading.Lock()
1205
+ # Threads currently blocked in acquire(). Callers that wait on a batch
1206
+ # of throttled futures size their timeouts from this queue depth.
1207
+ self._waiting = 0
1208
+
1209
+ @property
1210
+ def waiting(self) -> int:
1211
+ """Threads currently blocked in :meth:`acquire`."""
1212
+ with self._lock:
1213
+ return self._waiting
296
1214
 
297
1215
  def acquire(self) -> None:
298
1216
  """Consume one token, blocking only when the bucket is empty."""
299
- while True:
300
- with self._lock:
301
- now = time.monotonic()
302
- # Clamp elapsed to >= 0: a backward clock reading must never
303
- # drive tokens negative (which would spin this loop forever).
304
- elapsed = max(0.0, now - self._last)
305
- self._tokens = min(self.capacity, self._tokens + elapsed * self.rate)
306
- self._last = now
307
- if self._tokens >= 1.0:
308
- self._tokens -= 1.0
309
- return
310
- wait = (1.0 - self._tokens) / self.rate
311
- time.sleep(wait)
312
-
313
-
314
- # Shared across all keyless Reddit tiers (RSS, listing, shreddit) so their
1217
+ queued = False
1218
+ try:
1219
+ while True:
1220
+ with self._lock:
1221
+ now = time.monotonic()
1222
+ # Clamp elapsed to >= 0: a backward clock reading must never
1223
+ # drive tokens negative (which would spin this loop forever).
1224
+ elapsed = max(0.0, now - self._last)
1225
+ self._tokens = min(self.capacity, self._tokens + elapsed * self.rate)
1226
+ self._last = now
1227
+ if self._tokens >= 1.0:
1228
+ self._tokens -= 1.0
1229
+ return
1230
+ if not queued:
1231
+ self._waiting += 1
1232
+ queued = True
1233
+ wait = (1.0 - self._tokens) / self.rate
1234
+ time.sleep(wait)
1235
+ finally:
1236
+ if queued:
1237
+ with self._lock:
1238
+ self._waiting -= 1
1239
+
1240
+
1241
+ # Shared across all keyless Reddit tiers (site search, listing, shreddit) so their
315
1242
  # combined fan-out is throttled as one family. Burst lets the parallel
316
1243
  # enrichment workers proceed; sustained rate caps the stampede.
317
- REDDIT_KEYLESS_LIMITER = RateLimiter(rate_per_sec=5.0, burst=5)
1244
+ # 1 req/sec is slow enough that home IPs survive search + listing + shreddit
1245
+ # fan-out; raise LAST30DAYS_REDDIT_KEYLESS_RATE to trade 429s for wall-clock.
1246
+ REDDIT_KEYLESS_RATE_ENV = "LAST30DAYS_REDDIT_KEYLESS_RATE"
1247
+ DEFAULT_REDDIT_KEYLESS_RATE = 1.0
1248
+ DEFAULT_REDDIT_KEYLESS_BURST = 2
1249
+ _REDDIT_429_RETRY_SLEEP_SEC = 1.0
1250
+ _REDDIT_429_RETRY_JITTER_SEC = 0.5
1251
+
1252
+
1253
+ def parse_reddit_keyless_rate(raw: Optional[str]) -> float:
1254
+ """Parse LAST30DAYS_REDDIT_KEYLESS_RATE; invalid/non-positive -> default."""
1255
+ text = (raw or "").strip()
1256
+ if not text:
1257
+ return DEFAULT_REDDIT_KEYLESS_RATE
1258
+ try:
1259
+ rate = float(text)
1260
+ except (TypeError, ValueError):
1261
+ return DEFAULT_REDDIT_KEYLESS_RATE
1262
+ if not math.isfinite(rate) or rate <= 0:
1263
+ return DEFAULT_REDDIT_KEYLESS_RATE
1264
+ return rate
1265
+
1266
+
1267
+ def make_reddit_keyless_limiter(
1268
+ environ: Optional[Dict[str, str]] = None,
1269
+ ) -> RateLimiter:
1270
+ envmap = os.environ if environ is None else environ
1271
+ return RateLimiter(
1272
+ rate_per_sec=parse_reddit_keyless_rate(envmap.get(REDDIT_KEYLESS_RATE_ENV)),
1273
+ burst=DEFAULT_REDDIT_KEYLESS_BURST,
1274
+ )
1275
+
1276
+
1277
+ REDDIT_KEYLESS_LIMITER = make_reddit_keyless_limiter()
1278
+
1279
+
1280
+ def _sync_reddit_keyless_rate() -> None:
1281
+ """Apply a process-env override without resetting in-flight tokens."""
1282
+ rate = parse_reddit_keyless_rate(os.environ.get(REDDIT_KEYLESS_RATE_ENV))
1283
+ if REDDIT_KEYLESS_LIMITER.rate != rate:
1284
+ REDDIT_KEYLESS_LIMITER.rate = rate
1285
+
1286
+
1287
+ def _failures_are_429(failures: list[HTTPError]) -> bool:
1288
+ if not failures:
1289
+ return False
1290
+ last = failures[-1]
1291
+ return last.status_code == 429 or last.outcome_state == health.RATE_LIMITED
1292
+
1293
+
1294
+ def _sleep_reddit_429_retry() -> None:
1295
+ """Short jittered pause before the single in-lane 429 retry."""
1296
+ time.sleep(
1297
+ _REDDIT_429_RETRY_SLEEP_SEC
1298
+ + random.uniform(0.0, _REDDIT_429_RETRY_JITTER_SEC)
1299
+ )
1300
+
1301
+
1302
+ # Run-scoped memo for keyless Reddit GETs. Subreddit listing partials, site
1303
+ # search pages, arctic supplements, and shreddit comment pages depend only on
1304
+ # the subreddit, sort, or raw topic, and the Reddit lane is dispatched with the
1305
+ # raw topic for every subquery, so a four-subquery run requested each of them
1306
+ # four times.
1307
+ # Memoizing successful bodies for the life of one command turns ~184 requests
1308
+ # into ~50 on the measured 2026-08-31 run shape. Concurrent requesters for the
1309
+ # same URL wait on the first fetch instead of issuing their own (all four
1310
+ # subquery streams start at once, so a result-only cache would miss).
1311
+ REDDIT_KEYLESS_MEMO_MAX = 512
1312
+ _REDDIT_KEYLESS_MEMO: "OrderedDict[str, str]" = OrderedDict()
1313
+ _REDDIT_KEYLESS_INFLIGHT: Dict[str, threading.Event] = {}
1314
+ _REDDIT_KEYLESS_MEMO_LOCK = threading.Lock()
1315
+
1316
+
1317
+ # Queue depth only counts threads already blocked in acquire(). The other
1318
+ # lanes' workers submit their requests as they go, so a batch's last fetch can
1319
+ # start well after the depth seen at wait time. This flat allowance covers
1320
+ # that (the 2026-08-31 smoke lost three feeds at ~35s with the depth term
1321
+ # alone; a full run's ~50 distinct keyless requests take ~50s at 1 req/s).
1322
+ REDDIT_KEYLESS_CONTENTION_SECONDS = 45.0
1323
+
1324
+
1325
+ def reddit_keyless_wait_allowance(batch_size: int) -> float:
1326
+ """Seconds a batch of *batch_size* throttled fetches may spend waiting for tokens.
1327
+
1328
+ Every keyless Reddit lane in a run shares one bucket, so a lane's futures
1329
+ can sit behind other lanes' requests before their own fetch starts. Size
1330
+ per-future result timeouts as ``base + this`` instead of a fixed number;
1331
+ at 1 req/s a fixed 20-second timeout expired on real runs while the fetch
1332
+ was still queued (issue #985 follow-up).
1333
+ """
1334
+ _sync_reddit_keyless_rate()
1335
+ limiter = REDDIT_KEYLESS_LIMITER
1336
+ rate = limiter.rate if limiter.rate > 0 else 1.0
1337
+ return (limiter.waiting + max(0, batch_size)) / rate + REDDIT_KEYLESS_CONTENTION_SECONDS
1338
+
1339
+
1340
+ def reset_reddit_keyless_memo() -> None:
1341
+ """Forget memoized keyless Reddit bodies. Called once per command, and by tests."""
1342
+ with _REDDIT_KEYLESS_MEMO_LOCK:
1343
+ _REDDIT_KEYLESS_MEMO.clear()
1344
+ _REDDIT_KEYLESS_INFLIGHT.clear()
1345
+
1346
+
1347
+ def _reddit_memo_get(url: str) -> Optional[str]:
1348
+ with _REDDIT_KEYLESS_MEMO_LOCK:
1349
+ text = _REDDIT_KEYLESS_MEMO.get(url)
1350
+ if text is not None:
1351
+ _REDDIT_KEYLESS_MEMO.move_to_end(url)
1352
+ return text
1353
+
1354
+
1355
+ def _reddit_memo_put(url: str, text: str) -> None:
1356
+ with _REDDIT_KEYLESS_MEMO_LOCK:
1357
+ _REDDIT_KEYLESS_MEMO[url] = text
1358
+ _REDDIT_KEYLESS_MEMO.move_to_end(url)
1359
+ while len(_REDDIT_KEYLESS_MEMO) > REDDIT_KEYLESS_MEMO_MAX:
1360
+ _REDDIT_KEYLESS_MEMO.popitem(last=False)
318
1361
 
319
1362
 
320
1363
  def reddit_keyless_get_text(
@@ -323,15 +1366,117 @@ def reddit_keyless_get_text(
323
1366
  retries: int = 2,
324
1367
  accept: str = "*/*",
325
1368
  headers: Optional[Dict[str, str]] = None,
1369
+ validate: Optional[Callable[[str], Optional[str]]] = None,
326
1370
  ) -> Optional[str]:
327
- """get_text for the keyless Reddit tiers, throttled by a shared limiter.
1371
+ """get_text for the keyless Reddit tiers, memoized per run and throttled.
1372
+
1373
+ Same contract as :func:`get_text` (returns None on any failure) but a URL
1374
+ already fetched this command is served from the run memo without spending
1375
+ a limiter token, concurrent requesters for one URL share the in-flight
1376
+ fetch, and cold fetches are spaced via :data:`REDDIT_KEYLESS_LIMITER` so a
1377
+ broad multi-query run does not stampede Reddit's keyless endpoints.
328
1378
 
329
- Same contract as :func:`get_text` (returns None on any failure) but spaces
330
- requests via :data:`REDDIT_KEYLESS_LIMITER` so a broad multi-query run does
331
- not stampede Reddit's keyless endpoints and trip blocks.
1379
+ ``validate`` lets a caller reject a fetched body before it is memoized. It
1380
+ returns None for a body the caller recognizes, or a short reason string.
1381
+ A rejected body is recorded into the failure sink as schema drift and the
1382
+ call returns None, so an HTTP 200 challenge page is neither reported as a
1383
+ clean empty result nor served from the memo to later streams.
332
1384
  """
333
- REDDIT_KEYLESS_LIMITER.acquire()
334
- return get_text(url, timeout=timeout, retries=retries, accept=accept, headers=headers)
1385
+ cached = _reddit_memo_get(url)
1386
+ if cached is not None:
1387
+ return cached
1388
+ # Elect one owner per URL. A waiter whose owner failed re-enters the
1389
+ # election rather than fetching un-gated, so a failed fetch costs one
1390
+ # retry for the whole group, not one per waiter.
1391
+ for _round in range(3):
1392
+ with _REDDIT_KEYLESS_MEMO_LOCK:
1393
+ cached = _REDDIT_KEYLESS_MEMO.get(url)
1394
+ if cached is not None:
1395
+ return cached
1396
+ gate = _REDDIT_KEYLESS_INFLIGHT.get(url)
1397
+ owner = gate is None
1398
+ if owner:
1399
+ gate = threading.Event()
1400
+ _REDDIT_KEYLESS_INFLIGHT[url] = gate
1401
+ if owner:
1402
+ break
1403
+ # The owner may itself be queued in the shared bucket; wait for that
1404
+ # queue, not just for one socket timeout.
1405
+ gate.wait(
1406
+ timeout=timeout * max(1, retries) + reddit_keyless_wait_allowance(1)
1407
+ )
1408
+ cached = _reddit_memo_get(url)
1409
+ if cached is not None:
1410
+ return cached
1411
+ else:
1412
+ # Three failed owners in a row: give up quietly rather than pile on.
1413
+ return None
1414
+ try:
1415
+ _sync_reddit_keyless_rate()
1416
+ REDDIT_KEYLESS_LIMITER.acquire()
1417
+ text = get_text(url, timeout=timeout, retries=retries, accept=accept, headers=headers)
1418
+ if text is not None and validate is not None:
1419
+ problem = validate(text)
1420
+ if problem:
1421
+ _record_failure(HTTPError(
1422
+ f"Unrecognized response ({problem}): {url}",
1423
+ outcome_state=health.SCHEMA_DRIFT,
1424
+ ))
1425
+ return None
1426
+ if text is not None:
1427
+ _reddit_memo_put(url, text)
1428
+ return text
1429
+ finally:
1430
+ with _REDDIT_KEYLESS_MEMO_LOCK:
1431
+ _REDDIT_KEYLESS_INFLIGHT.pop(url, None)
1432
+ gate.set()
1433
+
1434
+
1435
+ def reddit_keyless_get_text_retry_429(
1436
+ url: str,
1437
+ timeout: int = DEFAULT_TIMEOUT,
1438
+ accept: str = "*/*",
1439
+ headers: Optional[Dict[str, str]] = None,
1440
+ validate: Optional[Callable[[str], Optional[str]]] = None,
1441
+ ) -> tuple[Optional[str], Optional[str]]:
1442
+ """Limiter-throttled GET with one extra limiter-respecting retry on 429.
1443
+
1444
+ Returns ``(body, error)``. The first attempt is captured locally so a
1445
+ recovered 429 is not left in the pipeline sink. A second 429, or any
1446
+ non-429 miss, is recorded as before. Internal ``get_text`` retries are
1447
+ skipped (``retries=1``) so the in-lane retry is the one that re-acquires
1448
+ the bucket. ``validate`` is passed through to
1449
+ :func:`reddit_keyless_get_text`; a rejected body is a non-429 miss.
1450
+ """
1451
+ # retries=1 on purpose: letting request() sleep out a 42-60s
1452
+ # x-ratelimit-reset inside a lane worker starves the whole batch (the
1453
+ # 2026-08-31 smoke lost 14 feeds to future timeouts with retries=2 versus
1454
+ # 6 with 1). A keyless 429 fails fast, the lane retries once after a short
1455
+ # jittered pause through the bucket, and the memo keeps the other streams
1456
+ # from re-requesting the same URL.
1457
+ kwargs: Dict[str, Any] = {
1458
+ "timeout": timeout,
1459
+ "retries": 1,
1460
+ "accept": accept,
1461
+ "headers": headers,
1462
+ "validate": validate,
1463
+ }
1464
+ with capture_failures() as first:
1465
+ text = reddit_keyless_get_text(url, **kwargs)
1466
+ if text is not None:
1467
+ return text, None
1468
+ if _failures_are_429(first):
1469
+ _sleep_reddit_429_retry()
1470
+ with tee_failures() as second:
1471
+ text = reddit_keyless_get_text(url, **kwargs)
1472
+ if text is not None:
1473
+ return text, None
1474
+ err = second[-1] if second else (first[-1] if first else None)
1475
+ return None, str(err) if err is not None else "no response"
1476
+ for err in first:
1477
+ _record_failure(err)
1478
+ err = first[-1] if first else None
1479
+ return None, str(err) if err is not None else "no response"
335
1480
 
336
1481
 
337
1482
  def scrapecreators_headers(token: str) -> Dict[str, str]: