luckiest-co 1.0.47 → 1.0.49
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/ab-testing/CHANGELOG.md +5 -0
- package/skills/ab-testing/SKILL.md +12 -9
- package/skills/ab-testing/evals/evals.json +18 -7
- package/skills/ab-testing/references/sample-size-guide.md +2 -2
- package/skills/ads/CHANGELOG.md +5 -0
- package/skills/ads/SKILL.md +4 -4
- package/skills/ai-seo/CHANGELOG.md +7 -0
- package/skills/ai-seo/SKILL.md +27 -20
- package/skills/analytics/CHANGELOG.md +6 -0
- package/skills/analytics/SKILL.md +4 -3
- package/skills/analytics/evals/evals.json +27 -0
- package/skills/analytics/references/ga4-implementation.md +7 -3
- package/skills/analytics/references/gtm-implementation.md +8 -2
- package/skills/aso/CHANGELOG.md +5 -0
- package/skills/aso/SKILL.md +4 -4
- package/skills/aso/evals/evals.json +15 -2
- package/skills/aso/references/benchmarks.md +4 -2
- package/skills/aso/references/google-play-specs.md +5 -1
- package/skills/brainstorm/SKILL.md +59 -16
- package/skills/brainstorm/builder-check-prompt.md +23 -0
- package/skills/churn-prevention/CHANGELOG.md +5 -0
- package/skills/churn-prevention/SKILL.md +3 -3
- package/skills/churn-prevention/evals/evals.json +14 -0
- package/skills/churn-prevention/references/dunning-playbook.md +6 -1
- package/skills/coder-brainstorming/CHANGELOG.md +10 -0
- package/skills/coder-brainstorming/SKILL.md +62 -20
- package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
- package/skills/coder-brainstorming/scripts/server.cjs +1 -1
- package/skills/coder-debugging/CHANGELOG.md +4 -0
- package/skills/coder-debugging/SKILL.md +5 -5
- package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
- package/skills/coder-requesting-code-review/SKILL.md +4 -4
- package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
- package/skills/coder-subagent-driven-development/SKILL.md +3 -3
- package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
- package/skills/cold-email/CHANGELOG.md +7 -0
- package/skills/cold-email/SKILL.md +27 -7
- package/skills/cold-email/evals/evals.json +30 -0
- package/skills/cold-email/references/benchmarks.md +15 -11
- package/skills/cold-email/references/deliverability.md +110 -0
- package/skills/cold-email/references/linkedin-outreach.md +91 -0
- package/skills/cold-email/references/multichannel-cadence.md +79 -0
- package/skills/cold-email/references/outbound-plays.md +88 -0
- package/skills/cold-email/references/personalization.md +11 -0
- package/skills/cold-email/references/reply-handling.md +65 -0
- package/skills/competitor-profiling/CHANGELOG.md +5 -0
- package/skills/competitor-profiling/SKILL.md +4 -4
- package/skills/content-strategy/CHANGELOG.md +5 -0
- package/skills/content-strategy/SKILL.md +5 -3
- package/skills/copy-editing/CHANGELOG.md +5 -0
- package/skills/copy-editing/SKILL.md +5 -5
- package/skills/copy-editing/references/content-refresh.md +33 -0
- package/skills/cro/CHANGELOG.md +5 -0
- package/skills/cro/SKILL.md +14 -3
- package/skills/cro/references/form.md +1 -1
- package/skills/directory-submissions/CHANGELOG.md +5 -0
- package/skills/directory-submissions/SKILL.md +13 -4
- package/skills/directory-submissions/evals/evals.json +15 -1
- package/skills/directory-submissions/references/directory-list.md +2 -4
- package/skills/emails/CHANGELOG.md +5 -0
- package/skills/emails/SKILL.md +14 -4
- package/skills/launch/CHANGELOG.md +7 -0
- package/skills/launch/SKILL.md +8 -4
- package/skills/launch/evals/evals.json +29 -0
- package/skills/launch/references/shipped-changes.md +84 -0
- package/skills/launch/references/site-launch-qa.md +55 -0
- package/skills/lead-magnets/CHANGELOG.md +5 -0
- package/skills/lead-magnets/SKILL.md +6 -4
- package/skills/lead-magnets/evals/evals.json +18 -0
- package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
- package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
- package/skills/lead-magnets/references/content-to-skill.md +108 -0
- package/skills/marketing-plan/CHANGELOG.md +5 -0
- package/skills/marketing-plan/SKILL.md +3 -3
- package/skills/marketing-plan/evals/evals.json +13 -0
- package/skills/marketing-plan/references/budget-planning.md +26 -17
- package/skills/model-router/CHANGELOG.md +6 -0
- package/skills/model-router/SKILL.md +3 -3
- package/skills/model-router/scripts/route.py +1 -1
- package/skills/model-router/scripts/test_route.py +5 -0
- package/skills/pricing/CHANGELOG.md +5 -0
- package/skills/pricing/SKILL.md +4 -4
- package/skills/pricing/evals/evals.json +12 -0
- package/skills/pricing/references/research-methods.md +8 -4
- package/skills/programmatic-seo/CHANGELOG.md +5 -0
- package/skills/programmatic-seo/SKILL.md +8 -4
- package/skills/programmatic-seo/evals/evals.json +13 -0
- package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
- package/skills/prospecting/CHANGELOG.md +7 -0
- package/skills/prospecting/SKILL.md +27 -13
- package/skills/prospecting/evals/evals.json +41 -0
- package/skills/prospecting/references/account-research.md +59 -0
- package/skills/prospecting/references/b2b-prospecting.md +1 -1
- package/skills/prospecting/references/compliance.md +44 -6
- package/skills/prospecting/references/data-sources.md +13 -15
- package/skills/prospecting/references/enrichment-playbook.md +61 -0
- package/skills/prospecting/references/saas-prospecting.md +1 -1
- package/skills/prospecting/references/signal-plays.md +99 -0
- package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
- package/skills/referrals/CHANGELOG.md +5 -0
- package/skills/referrals/SKILL.md +10 -4
- package/skills/referrals/evals/evals.json +99 -0
- package/skills/referrals/references/affiliate-operations.md +64 -0
- package/skills/referrals/references/affiliate-programs.md +2 -2
- package/skills/referrals/references/program-examples.md +14 -6
- package/skills/referrals/references/review-platform-rules.md +36 -0
- package/skills/referrals/references/review-program-operations.md +62 -0
- package/skills/referrals/references/review-programs.md +96 -0
- package/skills/revops/CHANGELOG.md +5 -0
- package/skills/revops/SKILL.md +10 -6
- package/skills/revops/evals/evals.json +13 -0
- package/skills/revops/references/automation-playbooks.md +1 -1
- package/skills/revops/references/inbound-routing.md +43 -0
- package/skills/revops/references/lifecycle-definitions.md +31 -0
- package/skills/sales-enablement/CHANGELOG.md +5 -0
- package/skills/sales-enablement/SKILL.md +6 -4
- package/skills/sales-enablement/evals/evals.json +14 -0
- package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
- package/skills/schema/CHANGELOG.md +5 -0
- package/skills/schema/SKILL.md +20 -8
- package/skills/schema/evals/evals.json +40 -0
- package/skills/schema/references/schema-examples.md +9 -15
- package/skills/seo-audit/CHANGELOG.md +5 -0
- package/skills/seo-audit/SKILL.md +10 -16
- package/skills/seo-audit/evals/evals.json +68 -0
- package/skills/seo-audit/references/international-seo.md +9 -11
- package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
- package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
- package/skills/seo-audit/references/local-seo.md +86 -0
- package/skills/seo-audit/references/rankings-push.md +62 -0
- package/skills/seo-audit/references/title-tags.md +89 -0
- package/skills/signup/CHANGELOG.md +5 -0
- package/skills/signup/SKILL.md +7 -3
- package/skills/site-architecture/CHANGELOG.md +5 -0
- package/skills/site-architecture/SKILL.md +5 -4
- package/skills/site-architecture/evals/evals.json +25 -0
- package/skills/site-architecture/references/platforms-and-migration.md +84 -0
- package/skills/social/CHANGELOG.md +5 -0
- package/skills/social/SKILL.md +4 -4
- package/skills/trends/.skillignore +3 -0
- package/skills/trends/CHANGELOG.md +20 -0
- package/skills/trends/SKILL.md +12 -8
- package/skills/trends/references/agent-mode.md +36 -0
- package/skills/trends/references/category-peers.md +38 -0
- package/skills/trends/references/comparison.md +227 -0
- package/skills/trends/references/competitors.md +29 -0
- package/skills/trends/references/discovery.md +90 -0
- package/skills/trends/references/engine-contract.md +111 -1889
- package/skills/trends/references/followups.md +112 -0
- package/skills/trends/references/grok-bot-x.md +55 -0
- package/skills/trends/references/hiring-signals.md +19 -0
- package/skills/trends/references/library-queue.md +33 -0
- package/skills/trends/references/recommendations.md +86 -0
- package/skills/trends/references/research-runbook.md +781 -0
- package/skills/trends/references/runtime.md +165 -0
- package/skills/trends/references/save-html-brief.md +39 -20
- package/skills/trends/references/setup-wizard.md +253 -0
- package/skills/trends/references/synthesis.md +289 -0
- package/skills/trends/scripts/box_chrome_login.py +209 -0
- package/skills/trends/scripts/briefing.py +9 -17
- package/skills/trends/scripts/last30days.py +3247 -280
- package/skills/trends/scripts/lib/agentcookie.py +196 -0
- package/skills/trends/scripts/lib/amazon.py +877 -0
- package/skills/trends/scripts/lib/arxiv.py +42 -8
- package/skills/trends/scripts/lib/backends.py +769 -0
- package/skills/trends/scripts/lib/bird_x.py +340 -57
- package/skills/trends/scripts/lib/bluesky.py +84 -10
- package/skills/trends/scripts/lib/bounded_get.py +156 -0
- package/skills/trends/scripts/lib/brightdata.py +291 -0
- package/skills/trends/scripts/lib/categories.py +7 -1
- package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
- package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
- package/skills/trends/scripts/lib/cluster.py +31 -54
- package/skills/trends/scripts/lib/competitors.py +10 -1
- package/skills/trends/scripts/lib/cookie_extract.py +221 -44
- package/skills/trends/scripts/lib/cookie_paths.py +27 -0
- package/skills/trends/scripts/lib/corpus.py +439 -0
- package/skills/trends/scripts/lib/devto.py +313 -0
- package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
- package/skills/trends/scripts/lib/doctor.py +2166 -0
- package/skills/trends/scripts/lib/dripstack.py +203 -0
- package/skills/trends/scripts/lib/entity_extract.py +37 -0
- package/skills/trends/scripts/lib/env.py +1092 -138
- package/skills/trends/scripts/lib/fanout.py +11 -1
- package/skills/trends/scripts/lib/feed.py +61 -0
- package/skills/trends/scripts/lib/fetch_memo.py +135 -0
- package/skills/trends/scripts/lib/freshness.py +566 -0
- package/skills/trends/scripts/lib/fusion.py +314 -28
- package/skills/trends/scripts/lib/github.py +438 -24
- package/skills/trends/scripts/lib/grok_x.py +1237 -0
- package/skills/trends/scripts/lib/grounding.py +129 -11
- package/skills/trends/scripts/lib/hackernews.py +61 -4
- package/skills/trends/scripts/lib/health.py +373 -0
- package/skills/trends/scripts/lib/hosted.py +333 -0
- package/skills/trends/scripts/lib/html_publish.py +34 -0
- package/skills/trends/scripts/lib/html_render.py +169 -3
- package/skills/trends/scripts/lib/http.py +1215 -70
- package/skills/trends/scripts/lib/instagram.py +132 -9
- package/skills/trends/scripts/lib/jobs.py +4 -2
- package/skills/trends/scripts/lib/library.py +308 -0
- package/skills/trends/scripts/lib/library_index.py +477 -0
- package/skills/trends/scripts/lib/linkedin.py +271 -24
- package/skills/trends/scripts/lib/log.py +5 -2
- package/skills/trends/scripts/lib/meta_ads.py +982 -0
- package/skills/trends/scripts/lib/normalize.py +382 -28
- package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
- package/skills/trends/scripts/lib/permission_preflight.py +42 -3
- package/skills/trends/scripts/lib/perplexity.py +1080 -468
- package/skills/trends/scripts/lib/pipeline.py +5079 -211
- package/skills/trends/scripts/lib/planner.py +396 -53
- package/skills/trends/scripts/lib/polymarket.py +285 -23
- package/skills/trends/scripts/lib/prescriptions.py +432 -0
- package/skills/trends/scripts/lib/private_files.py +21 -0
- package/skills/trends/scripts/lib/providers.py +231 -10
- package/skills/trends/scripts/lib/quality_nudge.py +122 -37
- package/skills/trends/scripts/lib/reddit.py +177 -12
- package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
- package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
- package/skills/trends/scripts/lib/reddit_listing.py +215 -14
- package/skills/trends/scripts/lib/reddit_public.py +12 -13
- package/skills/trends/scripts/lib/reddit_search.py +304 -0
- package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
- package/skills/trends/scripts/lib/registers.py +134 -0
- package/skills/trends/scripts/lib/relevance.py +17 -0
- package/skills/trends/scripts/lib/render.py +2161 -271
- package/skills/trends/scripts/lib/rerank.py +623 -55
- package/skills/trends/scripts/lib/resolve.py +50 -1
- package/skills/trends/scripts/lib/safari_cookies.py +42 -15
- package/skills/trends/scripts/lib/schema.py +782 -1
- package/skills/trends/scripts/lib/setup_wizard.py +779 -120
- package/skills/trends/scripts/lib/signals.py +206 -7
- package/skills/trends/scripts/lib/stocktwits.py +374 -0
- package/skills/trends/scripts/lib/subproc.py +307 -53
- package/skills/trends/scripts/lib/techmeme.py +89 -64
- package/skills/trends/scripts/lib/telegram.py +317 -0
- package/skills/trends/scripts/lib/tiktok.py +13 -9
- package/skills/trends/scripts/lib/topic_shape.py +340 -0
- package/skills/trends/scripts/lib/transcribe.py +12 -3
- package/skills/trends/scripts/lib/trustpilot.py +256 -5
- package/skills/trends/scripts/lib/truthsocial.py +9 -1
- package/skills/trends/scripts/lib/ui.py +6 -0
- package/skills/trends/scripts/lib/usage.py +132 -0
- package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
- package/skills/trends/scripts/lib/windows_job.py +138 -0
- package/skills/trends/scripts/lib/x_api.py +824 -0
- package/skills/trends/scripts/lib/x_envelope.py +733 -0
- package/skills/trends/scripts/lib/x_judge.py +292 -0
- package/skills/trends/scripts/lib/xai_x.py +26 -3
- package/skills/trends/scripts/lib/xquik.py +89 -15
- package/skills/trends/scripts/lib/xurl_x.py +284 -91
- package/skills/trends/scripts/lib/youtube_yt.py +570 -119
- package/skills/trends/scripts/setup-keychain.sh +12 -4
- package/skills/trends/scripts/setup-pass.sh +3 -0
- package/skills/trends/scripts/store.py +444 -50
- package/skills/trends/scripts/watchlist.py +38 -11
- package/skills/trends/scripts/x_lane_parity.py +107 -0
- package/skills/video-studio/CHANGELOG.md +4 -0
- package/skills/video-studio/SKILL.md +4 -4
- package/skills/video-studio/references/styles/product-flight.md +45 -0
- package/skills/video-studio/references/styles.md +3 -1
- package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
- package/skills/video-studio-motion/CHANGELOG.md +4 -0
- package/skills/video-studio-motion/SKILL.md +8 -5
- package/skills/youtube-planner/ATTRIBUTION.md +25 -0
- package/skills/youtube-planner/CHANGELOG.md +12 -0
- package/skills/youtube-planner/SKILL.md +191 -0
- package/skills/youtube-planner/references/plan-template.md +57 -0
- package/skills/youtube-planner/references/stages.md +119 -0
- package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
- package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
- 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
|
|
12
|
-
from
|
|
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__(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return
|
|
114
|
-
except urllib.error.HTTPError as
|
|
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 =
|
|
907
|
+
body = error.read().decode('utf-8')
|
|
118
908
|
except (OSError, UnicodeDecodeError):
|
|
119
909
|
pass
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1074
|
+
log_request(
|
|
177
1075
|
f"DNS resolution failure (attempt {dns_attempts}); "
|
|
178
1076
|
f"retrying in {delay:.1f}s"
|
|
179
1077
|
)
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
last_error = HTTPError(
|
|
193
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
|
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. "
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
self.
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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]:
|