techrecon 0.1.0__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.
techrecon/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """Python client for the TechRecon JA4 + web-crawl technology intelligence API.
2
+
3
+ from techrecon import TechRecon
4
+
5
+ client = TechRecon(api_key="trk_live_...")
6
+ result = client.lookup("t13d1516h2_8daaf6152771_b0da82dd1658")
7
+
8
+ See README.md for a full quickstart and https://api.techrecon.io/v1/docs for
9
+ the interactive API reference.
10
+ """
11
+
12
+ from .client import TechRecon
13
+ from .exceptions import (
14
+ APIError,
15
+ AuthenticationError,
16
+ BadRequestError,
17
+ ConflictError,
18
+ ForbiddenError,
19
+ NotFoundError,
20
+ PayloadTooLargeError,
21
+ RateLimitError,
22
+ ServiceUnavailableError,
23
+ TechReconError,
24
+ )
25
+
26
+ __version__ = "0.1.0"
27
+
28
+ __all__ = [
29
+ "TechRecon",
30
+ "TechReconError",
31
+ "APIError",
32
+ "AuthenticationError",
33
+ "ForbiddenError",
34
+ "NotFoundError",
35
+ "BadRequestError",
36
+ "RateLimitError",
37
+ "PayloadTooLargeError",
38
+ "ServiceUnavailableError",
39
+ "ConflictError",
40
+ "__version__",
41
+ ]
@@ -0,0 +1,51 @@
1
+ """Stdlib-only HTTP transport used by :class:`techrecon.client.TechRecon`.
2
+
3
+ The client depends on this module through a small protocol (any object with
4
+ a ``request`` method matching :meth:`Transport.request`), so tests can pass
5
+ a fake/mock transport instead of hitting the network. The default
6
+ :class:`Transport` uses only ``urllib.request`` from the standard library —
7
+ no third-party HTTP library is required to install or use this package.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from typing import Any, Dict, NamedTuple, Optional
14
+ from urllib import error as urllib_error
15
+ from urllib import request as urllib_request
16
+
17
+
18
+ class HTTPResponse(NamedTuple):
19
+ """The minimal response shape the client needs from a transport."""
20
+
21
+ status_code: int
22
+ body: bytes
23
+
24
+
25
+ class Transport:
26
+ """Default transport: issues requests with ``urllib.request``."""
27
+
28
+ def __init__(self, timeout: float = 30.0) -> None:
29
+ self.timeout = timeout
30
+
31
+ def request(
32
+ self,
33
+ method: str,
34
+ url: str,
35
+ headers: Dict[str, str],
36
+ json_body: Optional[Dict[str, Any]] = None,
37
+ ) -> HTTPResponse:
38
+ data = None
39
+ if json_body is not None:
40
+ data = json.dumps(json_body).encode("utf-8")
41
+
42
+ req = urllib_request.Request(url, data=data, headers=headers, method=method)
43
+ try:
44
+ with urllib_request.urlopen(req, timeout=self.timeout) as resp:
45
+ return HTTPResponse(status_code=resp.status, body=resp.read())
46
+ except urllib_error.HTTPError as exc:
47
+ # HTTPError is also a valid response with a body; urlopen raises
48
+ # it instead of returning it for non-2xx statuses.
49
+ return HTTPResponse(status_code=exc.code, body=exc.read())
50
+ except urllib_error.URLError as exc:
51
+ raise ConnectionError(f"failed to reach {url}: {exc.reason}") from exc
techrecon/client.py ADDED
@@ -0,0 +1,473 @@
1
+ """Synchronous client for the TechRecon API.
2
+
3
+ Generated against docs/openapi.yaml (spec version 1.1.0). Covers the stable,
4
+ public API surface — endpoints marked ``x-internal: true`` in the spec
5
+ (self-serve signup/email-verify, Stripe billing, and the privacy admin
6
+ routes) are intentionally out of scope for this client; they are beta,
7
+ admin-only, or superseded, per the spec's own descriptions.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from typing import Any, Dict, List, Optional
14
+ from urllib.parse import quote, urlencode
15
+
16
+ from . import models
17
+ from ._transport import Transport
18
+ from .exceptions import TechReconError, error_for_status
19
+
20
+ DEFAULT_BASE_URL = "https://api.techrecon.io"
21
+ USER_AGENT = "techrecon-python/0.1.0"
22
+
23
+
24
+ class TechRecon:
25
+ """Client for the TechRecon JA4 + web-crawl technology intelligence API.
26
+
27
+ Args:
28
+ api_key: Your TechRecon API key. Sent as the ``X-API-Key`` header on
29
+ every request (the spec also accepts it as an ``Authorization:
30
+ Bearer`` token, but this client always uses ``X-API-Key``).
31
+ base_url: API origin. Defaults to the production server
32
+ (``https://api.techrecon.io``); pass
33
+ ``base_url="http://localhost:8080"`` for local development.
34
+ timeout: Per-request timeout in seconds (default 30).
35
+ transport: Advanced/testing hook — an object implementing the same
36
+ ``request(method, url, headers, json_body)`` interface as
37
+ :class:`techrecon._transport.Transport`. Defaults to a stdlib
38
+ ``urllib``-based transport; not normally needed by callers.
39
+
40
+ Example:
41
+ >>> client = TechRecon(api_key="trk_live_...")
42
+ >>> result = client.lookup("t13d1516h2_8daaf6152771_b0da82dd1658")
43
+ >>> result["ja4_tech_match"]["technology"]
44
+ 'nginx'
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ api_key: str,
50
+ base_url: str = DEFAULT_BASE_URL,
51
+ timeout: float = 30.0,
52
+ transport: Optional[Transport] = None,
53
+ ) -> None:
54
+ if not api_key:
55
+ raise ValueError("api_key is required")
56
+ self.api_key = api_key
57
+ self.base_url = base_url.rstrip("/")
58
+ self._transport = transport if transport is not None else Transport(timeout=timeout)
59
+
60
+ # -- internals -----------------------------------------------------
61
+
62
+ def _headers(self) -> Dict[str, str]:
63
+ return {
64
+ "X-API-Key": self.api_key,
65
+ "Accept": "application/json",
66
+ "Content-Type": "application/json",
67
+ "User-Agent": USER_AGENT,
68
+ }
69
+
70
+ def _request(
71
+ self,
72
+ method: str,
73
+ path: str,
74
+ query: Optional[Dict[str, Any]] = None,
75
+ json_body: Optional[Dict[str, Any]] = None,
76
+ ) -> Any:
77
+ url = f"{self.base_url}{path}"
78
+ if query:
79
+ filtered = {k: v for k, v in query.items() if v is not None}
80
+ if filtered:
81
+ url = f"{url}?{urlencode(filtered)}"
82
+
83
+ response = self._transport.request(
84
+ method=method, url=url, headers=self._headers(), json_body=json_body
85
+ )
86
+
87
+ if response.status_code >= 400:
88
+ message = _extract_error_message(response.body)
89
+ raise error_for_status(response.status_code, message, response.body.decode("utf-8", "replace"))
90
+
91
+ if not response.body:
92
+ return None
93
+ try:
94
+ return json.loads(response.body)
95
+ except json.JSONDecodeError as exc:
96
+ raise TechReconError(
97
+ f"could not decode JSON response from {method} {path}: {exc}"
98
+ ) from exc
99
+
100
+ # -- Health / meta ---------------------------------------------------
101
+
102
+ def health(self) -> Dict[str, str]:
103
+ """``GET /healthz`` — liveness probe. No auth required."""
104
+ return self._request("GET", "/healthz")
105
+
106
+ def readiness(self) -> Dict[str, Any]:
107
+ """``GET /readyz`` — readiness probe (JA4 DB loaded, circuit breaker closed)."""
108
+ return self._request("GET", "/readyz")
109
+
110
+ # -- JA4 Lookup --------------------------------------------------------
111
+
112
+ def lookup(self, ja4_fingerprint: str, ip: Optional[str] = None) -> models.LookupResult:
113
+ """``POST /v1/lookup`` — look up a single JA4 fingerprint hash."""
114
+ body: Dict[str, Any] = {"ja4_fingerprint": ja4_fingerprint}
115
+ if ip is not None:
116
+ body["ip"] = ip
117
+ return self._request("POST", "/v1/lookup", json_body=body)
118
+
119
+ def bulk_lookup(self, requests: List[Dict[str, str]]) -> models.BulkLookupResponse:
120
+ """``POST /v1/bulk-lookup`` — look up up to 1,000 JA4 fingerprints.
121
+
122
+ Args:
123
+ requests: Each item is a dict like ``{"ja4_fingerprint": "...",
124
+ "ip": "..."}`` (``ip`` optional).
125
+ """
126
+ return self._request("POST", "/v1/bulk-lookup", json_body={"requests": requests})
127
+
128
+ def stats(self) -> models.JA4Stats:
129
+ """``GET /v1/stats`` — JA4 database hit/miss statistics."""
130
+ return self._request("GET", "/v1/stats")
131
+
132
+ def top_unknown(self) -> Dict[str, Any]:
133
+ """``GET /v1/unknown`` — top 10 unmatched JA4 fingerprints since startup."""
134
+ return self._request("GET", "/v1/unknown")
135
+
136
+ # -- Domain ------------------------------------------------------------
137
+
138
+ def get_domain(self, domain: str) -> models.DomainResponse:
139
+ """``GET /v1/domain/{domain}`` — technology + enrichment data for one domain."""
140
+ return self._request("GET", f"/v1/domain/{quote(domain, safe='')}")
141
+
142
+ def bulk_domain_lookup(self, domains: List[str]) -> models.BulkDomainResponse:
143
+ """``POST /v1/domains/bulk`` — detections for up to 100 domains at once."""
144
+ return self._request("POST", "/v1/domains/bulk", json_body={"domains": domains})
145
+
146
+ def get_domain_history(
147
+ self, domain: str, limit: Optional[int] = None, cursor: Optional[str] = None
148
+ ) -> models.DomainHistoryResponse:
149
+ """``GET /v1/domain/{domain}/history`` — active + removed-technology history."""
150
+ return self._request(
151
+ "GET",
152
+ f"/v1/domain/{quote(domain, safe='')}/history",
153
+ query={"limit": limit, "cursor": cursor},
154
+ )
155
+
156
+ def get_domain_changes(
157
+ self,
158
+ domain: str,
159
+ since: Optional[str] = None,
160
+ technology: Optional[str] = None,
161
+ type: Optional[str] = None,
162
+ limit: Optional[int] = None,
163
+ cursor: Optional[str] = None,
164
+ ) -> models.DomainChangesPage:
165
+ """``GET /v1/domain/{domain}/changes`` — change timeline for one domain."""
166
+ return self._request(
167
+ "GET",
168
+ f"/v1/domain/{quote(domain, safe='')}/changes",
169
+ query={
170
+ "since": since,
171
+ "technology": technology,
172
+ "type": type,
173
+ "limit": limit,
174
+ "cursor": cursor,
175
+ },
176
+ )
177
+
178
+ # -- Change feed ---------------------------------------------------------
179
+
180
+ def get_changes(
181
+ self,
182
+ since: str,
183
+ technology: Optional[str] = None,
184
+ type: Optional[str] = None,
185
+ domain: Optional[str] = None,
186
+ limit: Optional[int] = None,
187
+ cursor: Optional[str] = None,
188
+ ) -> models.ChangesPage:
189
+ """``GET /v1/changes`` — technology change feed since an RFC3339 timestamp (max 30 days)."""
190
+ return self._request(
191
+ "GET",
192
+ "/v1/changes",
193
+ query={
194
+ "since": since,
195
+ "technology": technology,
196
+ "type": type,
197
+ "domain": domain,
198
+ "limit": limit,
199
+ "cursor": cursor,
200
+ },
201
+ )
202
+
203
+ # -- Search --------------------------------------------------------------
204
+
205
+ def search(
206
+ self,
207
+ technology: Optional[str] = None,
208
+ category: Optional[str] = None,
209
+ cursor: Optional[str] = None,
210
+ limit: Optional[int] = None,
211
+ min_confidence: Optional[float] = None,
212
+ country: Optional[str] = None,
213
+ hosting_provider: Optional[str] = None,
214
+ min_rank: Optional[int] = None,
215
+ max_rank: Optional[int] = None,
216
+ min_security_score: Optional[int] = None,
217
+ ) -> models.SearchResponse:
218
+ """``GET /v1/search`` — search domains by technology or category.
219
+
220
+ At least one of ``technology`` or ``category`` is required by the API.
221
+ """
222
+ return self._request(
223
+ "GET",
224
+ "/v1/search",
225
+ query={
226
+ "technology": technology,
227
+ "category": category,
228
+ "cursor": cursor,
229
+ "limit": limit,
230
+ "min_confidence": min_confidence,
231
+ "country": country,
232
+ "hosting_provider": hosting_provider,
233
+ "min_rank": min_rank,
234
+ "max_rank": max_rank,
235
+ "min_security_score": min_security_score,
236
+ },
237
+ )
238
+
239
+ # -- Technology stats / trends --------------------------------------------
240
+
241
+ def get_tech_stats(
242
+ self, category: Optional[str] = None, limit: Optional[int] = None
243
+ ) -> models.TechStatsResponse:
244
+ """``GET /v1/technologies/stats`` — aggregated technology usage counts."""
245
+ return self._request(
246
+ "GET", "/v1/technologies/stats", query={"category": category, "limit": limit}
247
+ )
248
+
249
+ def get_tech_trend(self, tech: str, period: Optional[str] = None) -> models.TechTrendResponse:
250
+ """``GET /v1/technologies/{tech}/trend`` — daily adoption snapshots (e.g. ``period="90d"``)."""
251
+ return self._request(
252
+ "GET", f"/v1/technologies/{quote(tech, safe='')}/trend", query={"period": period}
253
+ )
254
+
255
+ # -- Exports ---------------------------------------------------------------
256
+
257
+ def create_export(
258
+ self,
259
+ technology: str,
260
+ format: str = "csv",
261
+ filters: Optional[Dict[str, Any]] = None,
262
+ ) -> models.CreateExportResponse:
263
+ """``POST /v1/exports`` — start an async bulk export; poll ``get_export_status``."""
264
+ body: Dict[str, Any] = {"technology": technology, "format": format}
265
+ if filters is not None:
266
+ body["filters"] = filters
267
+ return self._request("POST", "/v1/exports", json_body=body)
268
+
269
+ def get_export_status(self, export_id: str) -> models.ExportJob:
270
+ """``GET /v1/exports/{id}`` — status/metadata of an export job."""
271
+ return self._request("GET", f"/v1/exports/{quote(export_id, safe='')}")
272
+
273
+ def download_export(self, export_id: str) -> bytes:
274
+ """``GET /v1/exports/{id}/download`` — download a completed export's raw bytes.
275
+
276
+ Returns 409 (raised as :class:`techrecon.exceptions.ConflictError`) if
277
+ the job hasn't finished yet. The caller is responsible for decoding
278
+ the bytes as CSV or JSONL per the job's ``format``.
279
+ """
280
+ url = f"{self.base_url}/v1/exports/{quote(export_id, safe='')}/download"
281
+ response = self._transport.request(method="GET", url=url, headers=self._headers())
282
+ if response.status_code >= 400:
283
+ message = _extract_error_message(response.body)
284
+ raise error_for_status(
285
+ response.status_code, message, response.body.decode("utf-8", "replace")
286
+ )
287
+ return response.body
288
+
289
+ # -- System monitoring -------------------------------------------------------
290
+
291
+ def get_system_stats(self) -> models.SystemStats:
292
+ """``GET /v1/system/stats`` — PostgreSQL pool metrics, domain/change counters."""
293
+ return self._request("GET", "/v1/system/stats")
294
+
295
+ def get_system_health(self) -> models.HealthResponse:
296
+ """``GET /v1/system/health`` — crawler + queue + storage connectivity status."""
297
+ return self._request("GET", "/v1/system/health")
298
+
299
+ # -- Ingestion (system identity only) -----------------------------------------
300
+
301
+ def ingest_crawl_results(self, results: List[Dict[str, Any]]) -> models.IngestResponse:
302
+ """``POST /v1/crawl-results`` — ingest a batch of crawl results.
303
+
304
+ Restricted to the system identity (the crawler fleet's API key); an
305
+ ordinary tenant key gets ``403`` (raised as
306
+ :class:`techrecon.exceptions.ForbiddenError`).
307
+ """
308
+ return self._request("POST", "/v1/crawl-results", json_body={"results": results})
309
+
310
+ # -- Crawl request queue -------------------------------------------------------
311
+
312
+ def create_crawl_request(self, domain: str) -> models.CreateCrawlRequestResponse:
313
+ """``POST /v1/crawl-requests`` — queue a domain for crawling."""
314
+ return self._request("POST", "/v1/crawl-requests", json_body={"domain": domain})
315
+
316
+ def list_pending_crawl_requests(self) -> models.PendingCrawlRequestsResponse:
317
+ """``GET /v1/crawl-requests/pending`` — up to 50 pending requests (system identity only)."""
318
+ return self._request("GET", "/v1/crawl-requests/pending")
319
+
320
+ def get_crawl_request_status(self, request_id: int) -> models.CrawlRequest:
321
+ """``GET /v1/crawl-requests/{id}`` — status of a specific crawl request."""
322
+ return self._request("GET", f"/v1/crawl-requests/{request_id}")
323
+
324
+ # -- Priority crawl -------------------------------------------------------------
325
+
326
+ def submit_priority_crawl(
327
+ self,
328
+ ips: List[str],
329
+ consumer: str,
330
+ priority: Optional[str] = None,
331
+ callback_url: Optional[str] = None,
332
+ ttl_days: Optional[int] = None,
333
+ ) -> models.PriorityCrawlSubmitResponse:
334
+ """``POST /v1/priority-crawl/submit`` — push up to 50,000 IPs ahead of organic crawl."""
335
+ body: Dict[str, Any] = {"ips": ips, "consumer": consumer}
336
+ if priority is not None:
337
+ body["priority"] = priority
338
+ if callback_url is not None:
339
+ body["callback_url"] = callback_url
340
+ if ttl_days is not None:
341
+ body["ttl_days"] = ttl_days
342
+ return self._request("POST", "/v1/priority-crawl/submit", json_body=body)
343
+
344
+ def get_priority_crawl_status(self, batch_id: str) -> models.PriorityCrawlStatusResponse:
345
+ """``GET /v1/priority-crawl/status/{batch_id}`` — live queued/in-progress/completed counts."""
346
+ return self._request("GET", f"/v1/priority-crawl/status/{quote(batch_id, safe='')}")
347
+
348
+ # -- IP-keyed reverse lookup -----------------------------------------------------
349
+
350
+ def ip_batch_lookup(
351
+ self, ips: List[str], include_categories: Optional[List[str]] = None
352
+ ) -> models.IPBatchResponse:
353
+ """``POST /v1/ip/batch`` — up to 1,000 IPs; tech stacks observed at each."""
354
+ body: Dict[str, Any] = {"ips": ips}
355
+ if include_categories is not None:
356
+ body["include_categories"] = include_categories
357
+ return self._request("POST", "/v1/ip/batch", json_body=body)
358
+
359
+ # -- Fingerprint pivots ------------------------------------------------------------
360
+
361
+ def pivot_ja4(
362
+ self, ja4_hash: str, limit: Optional[int] = None, cursor: Optional[str] = None
363
+ ) -> models.FingerprintPivotPage:
364
+ """``GET /v1/pivot/ja4/{hash}`` — domains observed presenting a JA4 fingerprint."""
365
+ return self._request(
366
+ "GET",
367
+ f"/v1/pivot/ja4/{quote(ja4_hash, safe='')}",
368
+ query={"limit": limit, "cursor": cursor},
369
+ )
370
+
371
+ def pivot_jarm(
372
+ self, jarm_hash: str, limit: Optional[int] = None, cursor: Optional[str] = None
373
+ ) -> models.FingerprintPivotPage:
374
+ """``GET /v1/pivot/jarm/{hash}`` — domains observed presenting a JARM fingerprint."""
375
+ return self._request(
376
+ "GET",
377
+ f"/v1/pivot/jarm/{quote(jarm_hash, safe='')}",
378
+ query={"limit": limit, "cursor": cursor},
379
+ )
380
+
381
+ def pivot_favicon(
382
+ self, favicon_hash: str, limit: Optional[int] = None, cursor: Optional[str] = None
383
+ ) -> models.FingerprintPivotPage:
384
+ """``GET /v1/pivot/favicon/{hash}`` — domains observed serving a favicon (mmh3 hash)."""
385
+ return self._request(
386
+ "GET",
387
+ f"/v1/pivot/favicon/{quote(favicon_hash, safe='')}",
388
+ query={"limit": limit, "cursor": cursor},
389
+ )
390
+
391
+ # -- Alerts -----------------------------------------------------------------------
392
+
393
+ def create_alert(
394
+ self,
395
+ channel: str,
396
+ endpoint: str,
397
+ domain: Optional[str] = None,
398
+ tech_filter: Optional[str] = None,
399
+ ) -> models.Alert:
400
+ """``POST /v1/alerts`` — create a webhook/Slack alert rule.
401
+
402
+ ``domain`` defaults to ``"*"`` (all domains) and ``tech_filter``
403
+ defaults to ``"*"`` (any change) per the spec. Alert quota (50 free
404
+ tier / 5000 customer tier) is enforced per API key and returns
405
+ ``429`` (raised as :class:`techrecon.exceptions.RateLimitError`).
406
+ """
407
+ body: Dict[str, Any] = {"channel": channel, "endpoint": endpoint}
408
+ if domain is not None:
409
+ body["domain"] = domain
410
+ if tech_filter is not None:
411
+ body["tech_filter"] = tech_filter
412
+ return self._request("POST", "/v1/alerts", json_body=body)
413
+
414
+ def list_alerts(self) -> models.ListAlertsResponse:
415
+ """``GET /v1/alerts`` — all alert rules owned by the authenticated account."""
416
+ return self._request("GET", "/v1/alerts")
417
+
418
+ def delete_alert(self, alert_id: str) -> None:
419
+ """``DELETE /v1/alerts/{id}`` — delete an alert rule you own."""
420
+ self._request("DELETE", f"/v1/alerts/{quote(alert_id, safe='')}")
421
+
422
+ # -- Auth (session) -----------------------------------------------------------------
423
+
424
+ def login(self) -> models.LoginResponse:
425
+ """``POST /v1/auth/login`` — exchange this client's API key for a session cookie.
426
+
427
+ Note: this client is API-key-only and does not manage cookies; the
428
+ ``Set-Cookie: tr_session=...`` response header is not captured here.
429
+ Provided for completeness/parity with the spec's public surface.
430
+ """
431
+ return self._request("POST", "/v1/auth/login", json_body={"api_key": self.api_key})
432
+
433
+ def logout(self) -> None:
434
+ """``POST /v1/auth/logout`` — revoke the current session. Idempotent."""
435
+ self._request("POST", "/v1/auth/logout")
436
+
437
+ # -- Account / API keys / usage -------------------------------------------------------
438
+
439
+ def get_account(self) -> models.Account:
440
+ """``GET /v1/account`` — the caller's own account."""
441
+ return self._request("GET", "/v1/account")
442
+
443
+ def list_api_keys(self) -> models.ListAPIKeysResponse:
444
+ """``GET /v1/account/keys`` — display-safe metadata for the caller's API keys."""
445
+ return self._request("GET", "/v1/account/keys")
446
+
447
+ def issue_api_key(self, label: Optional[str] = None) -> models.NewAPIKey:
448
+ """``POST /v1/account/keys`` — mint a new API key; the raw key is returned once."""
449
+ body: Dict[str, Any] = {}
450
+ if label is not None:
451
+ body["label"] = label
452
+ return self._request("POST", "/v1/account/keys", json_body=body or None)
453
+
454
+ def revoke_api_key(self, key_id: str) -> None:
455
+ """``DELETE /v1/account/keys/{key_id}`` — revoke one of the caller's own API keys."""
456
+ self._request("DELETE", f"/v1/account/keys/{quote(key_id, safe='')}")
457
+
458
+ def get_account_usage(self) -> models.AccountUsage:
459
+ """``GET /v1/account/usage`` — current UTC-month usage against the plan's quota."""
460
+ return self._request("GET", "/v1/account/usage")
461
+
462
+
463
+ def _extract_error_message(body: bytes) -> str:
464
+ """Pull the ``error`` field out of an ErrorResponse body; fall back gracefully."""
465
+ if not body:
466
+ return "no error detail returned"
467
+ try:
468
+ parsed = json.loads(body)
469
+ except json.JSONDecodeError:
470
+ return body.decode("utf-8", "replace")
471
+ if isinstance(parsed, dict) and "error" in parsed:
472
+ return str(parsed["error"])
473
+ return body.decode("utf-8", "replace")
@@ -0,0 +1,98 @@
1
+ """Typed exceptions raised by the TechRecon client.
2
+
3
+ All API errors share the same JSON body shape defined by
4
+ ``components/schemas/ErrorResponse`` in docs/openapi.yaml::
5
+
6
+ {"error": "<message>"}
7
+
8
+ :class:`APIError` carries the parsed message plus the raw HTTP status code
9
+ and response body so callers can inspect anything the typed subclasses don't
10
+ surface directly.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Optional
16
+
17
+
18
+ class TechReconError(Exception):
19
+ """Base class for all errors raised by this package."""
20
+
21
+
22
+ class APIError(TechReconError):
23
+ """Raised when the API responds with a non-2xx status code.
24
+
25
+ Attributes:
26
+ status_code: The HTTP status code returned by the server.
27
+ message: The ``error`` field from the JSON error body, if present.
28
+ body: The raw response body text (for debugging when the body isn't
29
+ valid JSON or doesn't match the expected error shape).
30
+ """
31
+
32
+ def __init__(
33
+ self, status_code: int, message: str, body: Optional[str] = None
34
+ ) -> None:
35
+ self.status_code = status_code
36
+ self.message = message
37
+ self.body = body
38
+ super().__init__(f"HTTP {status_code}: {message}")
39
+
40
+
41
+ class AuthenticationError(APIError):
42
+ """401 Unauthorized — missing or invalid API key."""
43
+
44
+
45
+ class ForbiddenError(APIError):
46
+ """403 Forbidden — authenticated but not permitted for this endpoint.
47
+
48
+ Also raised for account-suspended responses (delinquent billing), which
49
+ the API returns as 403 on write/quota-bearing endpoints.
50
+ """
51
+
52
+
53
+ class NotFoundError(APIError):
54
+ """404 Not Found — the requested resource does not exist."""
55
+
56
+
57
+ class BadRequestError(APIError):
58
+ """400 Bad Request — invalid request body or missing parameter."""
59
+
60
+
61
+ class RateLimitError(APIError):
62
+ """429 Too Many Requests — rate limit or quota exceeded.
63
+
64
+ The spec uses 429 uniformly for both per-key rate limiting (e.g.
65
+ ``/v1/ip/batch``) and quota exhaustion (e.g. alert quota on
66
+ ``POST /v1/alerts``, API-key cap on ``POST /v1/account/keys``). There is
67
+ no 402 response anywhere in docs/openapi.yaml; 429 is the quota signal.
68
+ """
69
+
70
+
71
+ class PayloadTooLargeError(APIError):
72
+ """413 Payload Too Large — request body exceeds a size/count limit."""
73
+
74
+
75
+ class ServiceUnavailableError(APIError):
76
+ """503 Service Unavailable — a required storage backend isn't configured."""
77
+
78
+
79
+ class ConflictError(APIError):
80
+ """409 Conflict — e.g. downloading an export that isn't finished yet."""
81
+
82
+
83
+ _STATUS_TO_EXCEPTION = {
84
+ 400: BadRequestError,
85
+ 401: AuthenticationError,
86
+ 403: ForbiddenError,
87
+ 404: NotFoundError,
88
+ 409: ConflictError,
89
+ 413: PayloadTooLargeError,
90
+ 429: RateLimitError,
91
+ 503: ServiceUnavailableError,
92
+ }
93
+
94
+
95
+ def error_for_status(status_code: int, message: str, body: Optional[str] = None) -> APIError:
96
+ """Return the typed exception instance appropriate for an HTTP status code."""
97
+ exc_cls = _STATUS_TO_EXCEPTION.get(status_code, APIError)
98
+ return exc_cls(status_code, message, body)