osintkit 0.1.1__py3-none-any.whl

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.
osintkit/config.py ADDED
@@ -0,0 +1,151 @@
1
+ """Runtime settings, on-disk paths, and the response cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ APP_HOME_ENV = "OSINTKIT_HOME"
14
+ DEFAULT_BASE_URL = "https://api.venice.ai/api/v1"
15
+ DEFAULT_MODEL = "deepseek-v4-1-flash"
16
+ DEFAULT_TEMPERATURE = 0.3
17
+ DEFAULT_MAX_ATTEMPTS = 3
18
+ DEFAULT_TIMEOUT = 30.0
19
+ DEFAULT_CACHE_TTL = 24 * 60 * 60
20
+ DEFAULT_RESOLVERS = ("1.1.1.1", "8.8.8.8", "9.9.9.9")
21
+
22
+ PROVIDERS: dict[str, tuple[str, str]] = {
23
+ "venice": (DEFAULT_BASE_URL, DEFAULT_MODEL),
24
+ "openrouter": ("https://openrouter.ai/api/v1", "deepseek/deepseek-v4.1-flash-1017"),
25
+ "openai": ("https://api.openai.com/v1", "gpt-4.1-mini"),
26
+ }
27
+ KEY_PREFIXES: dict[str, str] = {"sk-or-": "openrouter", "sk-proj-": "openai"}
28
+
29
+
30
+ def provider_from_key(api_key: str | None) -> str | None:
31
+ """Infer a provider name from a recognisable key prefix."""
32
+ if not api_key:
33
+ return None
34
+ for prefix, provider in KEY_PREFIXES.items():
35
+ if api_key.startswith(prefix):
36
+ return provider
37
+ return None
38
+
39
+
40
+ def app_home() -> Path:
41
+ override = os.environ.get(APP_HOME_ENV)
42
+ if override:
43
+ return Path(override).expanduser()
44
+ return Path.home() / ".osintkit"
45
+
46
+
47
+ def cache_dir() -> Path:
48
+ return app_home() / "cache"
49
+
50
+
51
+ def log_dir() -> Path:
52
+ return app_home() / "logs"
53
+
54
+
55
+ def reports_dir() -> Path:
56
+ return app_home() / "reports"
57
+
58
+
59
+ def ensure_dirs() -> None:
60
+ for path in (cache_dir(), log_dir()):
61
+ path.mkdir(parents=True, exist_ok=True)
62
+
63
+
64
+ @dataclass(slots=True)
65
+ class Settings:
66
+ """Everything the run needs, resolved once from flags and the environment."""
67
+
68
+ target: str = ""
69
+ target_type: str = ""
70
+ base_url: str = DEFAULT_BASE_URL
71
+ model: str = DEFAULT_MODEL
72
+ api_key: str | None = None
73
+ temperature: float = DEFAULT_TEMPERATURE
74
+ max_tokens: int = 2400
75
+ timeout: float = DEFAULT_TIMEOUT
76
+ max_connections: int = 16
77
+ max_attempts: int = DEFAULT_MAX_ATTEMPTS
78
+ use_cache: bool = True
79
+ cache_ttl: int = DEFAULT_CACHE_TTL
80
+ resolvers: list[str] = field(default_factory=lambda: list(DEFAULT_RESOLVERS))
81
+ dry_run: bool = False
82
+ raw: bool = False
83
+ output_json: bool = False
84
+ output_markdown: bool = False
85
+ verbose: bool = False
86
+ extra: dict[str, Any] = field(default_factory=dict)
87
+
88
+ @property
89
+ def hibp_api_key(self) -> str | None:
90
+ return os.environ.get("HIBP_API_KEY") or None
91
+
92
+ @property
93
+ def github_token(self) -> str | None:
94
+ return os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or None
95
+
96
+ def resolved_api_key(self) -> str | None:
97
+ return self.api_key or os.environ.get("API_KEY") or os.environ.get("OPENAI_API_KEY")
98
+
99
+
100
+ def _cache_key(namespace: str, key: str) -> Path:
101
+ digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:32]
102
+ safe_ns = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in namespace)
103
+ return cache_dir() / safe_ns / f"{digest}.json"
104
+
105
+
106
+ def cache_read(namespace: str, key: str, ttl: int = DEFAULT_CACHE_TTL) -> Any | None:
107
+ path = _cache_key(namespace, key)
108
+ try:
109
+ stat = path.stat()
110
+ except OSError:
111
+ return None
112
+ if ttl > 0 and time.time() - stat.st_mtime > ttl:
113
+ return None
114
+ try:
115
+ return json.loads(path.read_text("utf-8"))
116
+ except (OSError, json.JSONDecodeError):
117
+ return None
118
+
119
+
120
+ def cache_write(namespace: str, key: str, value: Any) -> None:
121
+ path = _cache_key(namespace, key)
122
+ try:
123
+ path.parent.mkdir(parents=True, exist_ok=True)
124
+ tmp = path.with_suffix(".tmp")
125
+ tmp.write_text(json.dumps(value, ensure_ascii=False, default=str), "utf-8")
126
+ tmp.replace(path)
127
+ except OSError:
128
+ return
129
+
130
+
131
+ def cache_clear() -> int:
132
+ root = cache_dir()
133
+ if not root.exists():
134
+ return 0
135
+ removed = 0
136
+ for path in root.rglob("*.json"):
137
+ try:
138
+ path.unlink()
139
+ removed += 1
140
+ except OSError:
141
+ continue
142
+ return removed
143
+
144
+
145
+ def cache_stats() -> tuple[int, int]:
146
+ root = cache_dir()
147
+ if not root.exists():
148
+ return (0, 0)
149
+ files = list(root.rglob("*.json"))
150
+ size = sum(p.stat().st_size for p in files if p.exists())
151
+ return (len(files), size)
osintkit/diff.py ADDED
@@ -0,0 +1,96 @@
1
+ """Compare two archived reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .models import Report
8
+
9
+ SEVERITY = ("low", "medium", "high")
10
+
11
+
12
+ def _names(rows: list[dict[str, Any]], field: str = "name") -> dict[str, dict[str, Any]]:
13
+ out: dict[str, dict[str, Any]] = {}
14
+ for row in rows:
15
+ key = str(row.get(field) or "").strip().lower()
16
+ if key:
17
+ out[key] = row
18
+ return out
19
+
20
+
21
+ def _set_diff(old: list[str], new: list[str]) -> dict[str, list[str]]:
22
+ before = {item.strip().lower(): item.strip() for item in old if item.strip()}
23
+ after = {item.strip().lower(): item.strip() for item in new if item.strip()}
24
+ return {
25
+ "added": sorted(after[key] for key in after.keys() - before.keys()),
26
+ "removed": sorted(before[key] for key in before.keys() - after.keys()),
27
+ }
28
+
29
+
30
+ def compare(old: Report, new: Report) -> dict[str, Any]:
31
+ old_analysis = old.analysis
32
+ new_analysis = new.analysis
33
+ old_breaches = _names([b.model_dump() for b in old_analysis.breaches]) if old_analysis else {}
34
+ new_breaches = _names([b.model_dump() for b in new_analysis.breaches]) if new_analysis else {}
35
+
36
+ delta = _risk_delta(old, new)
37
+ old_status = {c.collector: (c.skipped, c.ok) for c in old.collectors}
38
+ new_status = {c.collector: (c.skipped, c.ok) for c in new.collectors}
39
+
40
+ return {
41
+ "old_target": old.target,
42
+ "new_target": new.target,
43
+ "target_changed": old.target != new.target,
44
+ "old_generated": old.generated_at,
45
+ "new_generated": new.generated_at,
46
+ "risk": {
47
+ "old": old_analysis.risk_level if old_analysis else None,
48
+ "new": new_analysis.risk_level if new_analysis else None,
49
+ },
50
+ "breaches": {
51
+ "added": sorted(new_breaches[k]["name"] for k in new_breaches.keys() - old_breaches.keys()),
52
+ "removed": sorted(old_breaches[k]["name"] for k in old_breaches.keys() - new_breaches.keys()),
53
+ "unchanged": len(old_breaches.keys() & new_breaches.keys()),
54
+ },
55
+ "subdomains": _set_diff(
56
+ old_analysis.subdomains if old_analysis else [],
57
+ new_analysis.subdomains if new_analysis else [],
58
+ ),
59
+ "collectors": {
60
+ "started_working": sorted(
61
+ name
62
+ for name, state in new_status.items()
63
+ if state[1] and not state[0] and old_status.get(name, (False, False))[1] is False
64
+ ),
65
+ "started_failing": sorted(
66
+ name
67
+ for name, state in new_status.items()
68
+ if not state[1] and not state[0] and old_status.get(name, (False, True))[1] is True
69
+ ),
70
+ },
71
+ "risk_rose": delta > 0,
72
+ "risk_fell": delta < 0,
73
+ }
74
+
75
+
76
+ def _risk_delta(old: Report, new: Report) -> int:
77
+ if not old.analysis or not new.analysis:
78
+ return 0
79
+ before = SEVERITY.index(old.analysis.risk_level) if old.analysis.risk_level in SEVERITY else 0
80
+ after = SEVERITY.index(new.analysis.risk_level) if new.analysis.risk_level in SEVERITY else 0
81
+ return after - before
82
+
83
+
84
+ def is_empty(diff: dict[str, Any]) -> bool:
85
+ return not any(
86
+ (
87
+ diff["breaches"]["added"],
88
+ diff["breaches"]["removed"],
89
+ diff["subdomains"]["added"],
90
+ diff["subdomains"]["removed"],
91
+ diff["collectors"]["started_working"],
92
+ diff["collectors"]["started_failing"],
93
+ diff["risk_rose"],
94
+ diff["risk_fell"],
95
+ )
96
+ )
osintkit/doctor.py ADDED
@@ -0,0 +1,119 @@
1
+ """Environment checks behind `osintkit doctor`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from dataclasses import dataclass, replace
8
+ from pathlib import Path
9
+
10
+ from .config import Settings, cache_dir, ensure_dirs, log_dir
11
+ from .http import HttpClient
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class Check:
16
+ name: str
17
+ status: str
18
+ detail: str
19
+
20
+
21
+ def _writable(path: Path) -> bool:
22
+ try:
23
+ ensure_dirs()
24
+ path.mkdir(parents=True, exist_ok=True)
25
+ with tempfile.NamedTemporaryFile(dir=path, delete=True):
26
+ return True
27
+ except OSError:
28
+ return False
29
+
30
+
31
+ async def _reach_endpoint(settings: Settings, deep: bool = False) -> Check:
32
+ key = settings.resolved_api_key()
33
+ if not key:
34
+ return Check("model endpoint", "warn", "no API key; synthesis will fail, dry runs still work")
35
+ base = settings.base_url.rstrip("/")
36
+ headers = {"Authorization": f"Bearer {key}"}
37
+ fresh = replace(settings, use_cache=False)
38
+
39
+ try:
40
+ async with HttpClient(fresh) as http:
41
+ response = await http.get(
42
+ f"{base}/models",
43
+ namespace="doctor",
44
+ headers=headers,
45
+ empty_on=tuple(range(400, 500)),
46
+ timeout=10.0,
47
+ )
48
+ if response.status_code >= 400 and response.status_code != 404:
49
+ return Check("model endpoint", "fail", f"HTTP {response.status_code} from {base}/models")
50
+
51
+ if not deep:
52
+ detail = (
53
+ f"{base} answered; key not exercised, use --deep"
54
+ if response.status_code < 400
55
+ else f"HTTP {response.status_code} from {base}/models; use --deep"
56
+ )
57
+ return Check("model endpoint", "ok" if response.status_code < 400 else "warn", detail)
58
+
59
+ probe = await http.post_json(
60
+ f"{base}/chat/completions",
61
+ namespace="doctor",
62
+ headers=headers,
63
+ payload={
64
+ "model": settings.model,
65
+ "messages": [{"role": "user", "content": "ping"}],
66
+ "max_tokens": 1,
67
+ "temperature": 0,
68
+ },
69
+ timeout=20.0,
70
+ empty_on=tuple(range(400, 500)),
71
+ )
72
+ except Exception as exc:
73
+ return Check("model endpoint", "fail", f"{base}: {type(exc).__name__}")
74
+
75
+ if probe.status_code in (401, 403):
76
+ return Check("model endpoint", "fail", f"key rejected (HTTP {probe.status_code})")
77
+ if probe.status_code == 404:
78
+ return Check("model endpoint", "fail", f"model {settings.model!r} or route not found (404)")
79
+ if probe.status_code >= 400:
80
+ return Check("model endpoint", "fail", f"completion probe failed (HTTP {probe.status_code})")
81
+ return Check("model endpoint", "ok", f"{base} accepted a completion for {settings.model}")
82
+
83
+
84
+ def collect_checks(settings: Settings) -> list[Check]:
85
+ key = settings.resolved_api_key()
86
+ checks = [
87
+ Check(
88
+ "api key",
89
+ "ok" if key else "warn",
90
+ "set" if key else "API_KEY is not set",
91
+ ),
92
+ Check(
93
+ "cache dir",
94
+ "ok" if _writable(cache_dir()) else "fail",
95
+ str(cache_dir()),
96
+ ),
97
+ Check(
98
+ "log dir",
99
+ "ok" if _writable(log_dir()) else "fail",
100
+ str(log_dir()),
101
+ ),
102
+ ]
103
+ for name, variable, why in (
104
+ ("Have I Been Pwned", "HIBP_API_KEY", "breach collectors will be skipped"),
105
+ ("GitHub", "GITHUB_TOKEN", "GitHub is limited to 60 requests/hour"),
106
+ ):
107
+ present = bool(os.environ.get(variable))
108
+ checks.append(Check(name, "ok" if present else "warn", "set" if present else why))
109
+ if key:
110
+ checks.append(Check("api key shape", "ok" if len(key) >= 20 else "warn", f"{len(key)} chars"))
111
+ return checks
112
+
113
+
114
+ async def diagnose(settings: Settings, *, deep: bool = False) -> list[Check]:
115
+ return [*collect_checks(settings), await _reach_endpoint(settings, deep)]
116
+
117
+
118
+ def failed(checks: list[Check]) -> bool:
119
+ return any(check.status == "fail" for check in checks)
osintkit/errors.py ADDED
@@ -0,0 +1,51 @@
1
+ """Errors and shared type aliases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ TargetType = Literal["email", "username", "domain", "company"]
8
+
9
+
10
+ class OsintKitError(Exception):
11
+ """Base error for osintkit."""
12
+
13
+
14
+ class CollectorError(OsintKitError):
15
+ """A collector could not complete its work."""
16
+
17
+
18
+ class RateLimited(OsintKitError):
19
+ """An upstream source asked us to slow down."""
20
+
21
+ def __init__(self, message: str, retry_after: float | None = None) -> None:
22
+ super().__init__(message)
23
+ self.retry_after = retry_after
24
+
25
+
26
+ class SourceUnavailable(OsintKitError):
27
+ """An upstream source is down or blocked."""
28
+
29
+
30
+ class SynthesisError(OsintKitError):
31
+ """The model returned something we could not validate."""
32
+
33
+
34
+ class MissingAPIKey(OsintKitError):
35
+ """No API key was supplied and none was found in the environment."""
36
+
37
+
38
+ class TargetParseError(OsintKitError):
39
+ """A target string could not be typed."""
40
+
41
+
42
+ __all__ = [
43
+ "CollectorError",
44
+ "MissingAPIKey",
45
+ "OsintKitError",
46
+ "RateLimited",
47
+ "SourceUnavailable",
48
+ "SynthesisError",
49
+ "TargetParseError",
50
+ "TargetType",
51
+ ]
osintkit/http.py ADDED
@@ -0,0 +1,272 @@
1
+ """Resilient HTTP client: retries, exponential backoff, and a warm cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import random
8
+ from collections.abc import Iterable, Mapping
9
+ from typing import Any, Self
10
+
11
+ import httpx
12
+
13
+ from .config import Settings, cache_read, cache_write
14
+ from .errors import RateLimited, SourceUnavailable
15
+
16
+ USER_AGENT = "osintkit/0.1 (+public-data-osint; contact: local user)"
17
+ RETRY_STATUS = {429, 500, 502, 503, 504}
18
+
19
+
20
+ class HttpClient:
21
+ """Thin async wrapper with bounded retries and transparent caching.
22
+
23
+ Only GETs are needed by the collectors, so that is all this exposes.
24
+ """
25
+
26
+ def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
27
+ self.settings = settings
28
+ self._inflight: dict[tuple[str, str], asyncio.Task[httpx.Response]] = {}
29
+ self._own_client = client is None
30
+ self._client = client or httpx.AsyncClient(
31
+ timeout=httpx.Timeout(settings.timeout),
32
+ limits=httpx.Limits(max_connections=settings.max_connections),
33
+ follow_redirects=True,
34
+ headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
35
+ )
36
+
37
+ async def __aenter__(self) -> Self:
38
+ return self
39
+
40
+ async def __aexit__(self, *exc: object) -> None:
41
+ await self.aclose()
42
+
43
+ async def aclose(self) -> None:
44
+ if self._own_client:
45
+ await self._client.aclose()
46
+
47
+ @staticmethod
48
+ def _cache_key(url: str, params: Mapping[str, Any] | None) -> str:
49
+ if not params:
50
+ return url
51
+ return f"{url}?{json.dumps(params, sort_keys=True, default=str)}"
52
+
53
+ async def get(
54
+ self,
55
+ url: str,
56
+ *,
57
+ namespace: str,
58
+ params: Mapping[str, Any] | None = None,
59
+ headers: Mapping[str, str] | None = None,
60
+ accept: str = "application/json",
61
+ empty_on: Iterable[int] = (404,),
62
+ timeout: float | None = None,
63
+ ) -> httpx.Response:
64
+ return await self._request(
65
+ "GET",
66
+ url,
67
+ namespace=namespace,
68
+ params=params,
69
+ headers=headers,
70
+ accept=accept,
71
+ empty_on=empty_on,
72
+ timeout=timeout,
73
+ )
74
+
75
+ async def post_json(
76
+ self,
77
+ url: str,
78
+ *,
79
+ namespace: str,
80
+ payload: Mapping[str, Any],
81
+ headers: Mapping[str, str] | None = None,
82
+ timeout: float | None = None,
83
+ empty_on: Iterable[int] = (),
84
+ ) -> httpx.Response:
85
+ return await self._request(
86
+ "POST",
87
+ url,
88
+ namespace=namespace,
89
+ payload=payload,
90
+ headers=headers,
91
+ empty_on=empty_on,
92
+ timeout=timeout,
93
+ )
94
+
95
+ async def _request(
96
+ self,
97
+ method: str,
98
+ url: str,
99
+ *,
100
+ namespace: str,
101
+ params: Mapping[str, Any] | None = None,
102
+ payload: Mapping[str, Any] | None = None,
103
+ headers: Mapping[str, str] | None = None,
104
+ accept: str = "application/json",
105
+ empty_on: Iterable[int] = (),
106
+ timeout: float | None = None,
107
+ ) -> httpx.Response:
108
+ key = self._cache_key(url, payload if method != "GET" else params)
109
+ if self.settings.use_cache:
110
+ cached = cache_read(namespace, key, self.settings.cache_ttl)
111
+ if cached is not None:
112
+ return httpx.Response(
113
+ status_code=cached["status"],
114
+ headers=cached.get("headers", {}),
115
+ content=cached.get("body", "").encode("utf-8"),
116
+ request=httpx.Request(method, url),
117
+ )
118
+
119
+ slot = (namespace, key)
120
+ pending = self._inflight.get(slot)
121
+ if pending is not None and not pending.done():
122
+ return await asyncio.shield(pending)
123
+
124
+ task = asyncio.ensure_future(
125
+ self._perform(
126
+ method,
127
+ url,
128
+ namespace=namespace,
129
+ key=key,
130
+ params=params,
131
+ payload=payload,
132
+ headers=headers,
133
+ accept=accept,
134
+ empty_on=empty_on,
135
+ timeout=timeout,
136
+ )
137
+ )
138
+ self._inflight[slot] = task
139
+ try:
140
+ return await task
141
+ finally:
142
+ if self._inflight.get(slot) is task:
143
+ self._inflight.pop(slot, None)
144
+
145
+ async def _perform(
146
+ self,
147
+ method: str,
148
+ url: str,
149
+ *,
150
+ namespace: str,
151
+ key: str,
152
+ params: Mapping[str, Any] | None = None,
153
+ payload: Mapping[str, Any] | None = None,
154
+ headers: Mapping[str, str] | None = None,
155
+ accept: str = "application/json",
156
+ empty_on: Iterable[int] = (),
157
+ timeout: float | None = None,
158
+ ) -> httpx.Response:
159
+ last_error: Exception | None = None
160
+ for attempt in range(1, self.settings.max_attempts + 1):
161
+ try:
162
+ request_headers = {"Accept": accept}
163
+ if headers:
164
+ request_headers.update(headers)
165
+ if method == "GET":
166
+ response = await self._client.get(
167
+ url, params=params, headers=request_headers, timeout=timeout
168
+ )
169
+ else:
170
+ response = await self._client.post(
171
+ url, json=dict(payload or {}), headers=request_headers, timeout=timeout
172
+ )
173
+ except (httpx.TimeoutException, httpx.TransportError) as exc:
174
+ last_error = exc
175
+ if attempt >= self.settings.max_attempts:
176
+ raise SourceUnavailable(f"{url}: {type(exc).__name__}") from exc
177
+ await self._sleep(attempt, None)
178
+ continue
179
+
180
+ if response.status_code in empty_on:
181
+ self._store(namespace, key, response)
182
+ return response
183
+
184
+ if response.status_code in RETRY_STATUS:
185
+ retry_after = _retry_after(response)
186
+ if attempt >= self.settings.max_attempts:
187
+ raise RateLimited(
188
+ f"{url}: HTTP {response.status_code} after {attempt} attempts",
189
+ retry_after=retry_after,
190
+ )
191
+ await self._sleep(attempt, retry_after)
192
+ continue
193
+
194
+ if response.status_code == 403 and _quota_exhausted(response):
195
+ if attempt >= self.settings.max_attempts:
196
+ raise RateLimited(f"{url}: quota exhausted after {attempt} attempts")
197
+ await self._sleep(attempt, _retry_after(response))
198
+ continue
199
+
200
+ if response.status_code >= 400:
201
+ raise SourceUnavailable(f"{url}: HTTP {response.status_code}")
202
+
203
+ self._store(namespace, key, response)
204
+ return response
205
+
206
+ raise SourceUnavailable(f"{url}: {last_error}")
207
+
208
+ async def get_json(self, url: str, **kwargs: Any) -> Any:
209
+ response = await self.get(url, **kwargs)
210
+ if response.status_code in set(kwargs.get("empty_on") or (404,)):
211
+ return None
212
+ try:
213
+ return response.json()
214
+ except (json.JSONDecodeError, ValueError) as exc: # pragma: no cover - malformed upstream
215
+ raise SourceUnavailable(f"{url}: invalid JSON") from exc
216
+
217
+ async def head_ok(self, url: str, *, namespace: str = "presence", **kwargs: Any) -> int:
218
+ """Return the final status code, treating any HTTP answer as a hit."""
219
+ response = await self.get(url, namespace=namespace, empty_on=tuple(range(400, 500)), **kwargs)
220
+ return response.status_code
221
+
222
+ def _store(self, namespace: str, key: str, response: httpx.Response) -> None:
223
+ if not self.settings.use_cache:
224
+ return
225
+ try:
226
+ body = response.text
227
+ except Exception: # pragma: no cover - undecodable body
228
+ body = ""
229
+ headers = {
230
+ name: value
231
+ for name, value in response.headers.items()
232
+ if name.lower() not in {"content-encoding", "content-length", "transfer-encoding"}
233
+ }
234
+ cache_write(
235
+ namespace,
236
+ key,
237
+ {"status": response.status_code, "headers": headers, "body": body},
238
+ )
239
+
240
+ async def _sleep(self, attempt: int, retry_after: float | None) -> None:
241
+ if retry_after:
242
+ delay = min(retry_after, 30.0)
243
+ else:
244
+ delay = min(0.5 * (2 ** (attempt - 1)), 8.0)
245
+ delay += random.uniform(0, delay / 2)
246
+ await asyncio.sleep(delay)
247
+
248
+
249
+ def _quota_exhausted(response: httpx.Response) -> bool:
250
+ return response.headers.get("x-ratelimit-remaining") == "0"
251
+
252
+
253
+ def _retry_after(response: httpx.Response) -> float | None:
254
+ raw = response.headers.get("retry-after")
255
+ if not raw:
256
+ return None
257
+ try:
258
+ return float(raw)
259
+ except ValueError:
260
+ return None
261
+
262
+
263
+ def cache_hit(settings: Settings, namespace: str, key: str, ttl: int | None = None) -> Any | None:
264
+ """Read a cached non-HTTP collector result (DNS, WHOIS, TCP probes)."""
265
+ if not settings.use_cache:
266
+ return None
267
+ return cache_read(namespace, key, settings.cache_ttl if ttl is None else ttl)
268
+
269
+
270
+ def cache_put(settings: Settings, namespace: str, key: str, value: Any) -> None:
271
+ if settings.use_cache:
272
+ cache_write(namespace, key, value)