spanforge 1.0.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.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
spanforge/sdk/_base.py
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
"""spanforge.sdk._base — Infrastructure base classes for the SpanForge service SDK.
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
* :class:`_CircuitBreaker` — thread-safe circuit breaker (5 failures → OPEN, 30 s reset).
|
|
5
|
+
* :class:`_SlidingWindowRateLimiter` — per-key sliding window rate limiter.
|
|
6
|
+
* :class:`SFClientConfig` — configuration dataclass loaded from env vars.
|
|
7
|
+
* :class:`SFServiceClient` — abstract base with HTTP retry + circuit breaker.
|
|
8
|
+
|
|
9
|
+
Security requirements
|
|
10
|
+
---------------------
|
|
11
|
+
* ``SFClientConfig.api_key`` is a :class:`~spanforge.sdk._types.SecretStr`.
|
|
12
|
+
* HTTP request bodies are sent as application/json; no credentials are ever
|
|
13
|
+
logged.
|
|
14
|
+
* Retry jitter uses :mod:`random` (not :mod:`secrets`) — non-secret values
|
|
15
|
+
only. Cryptographic randomness is reserved for key generation.
|
|
16
|
+
* TLS verification is enabled by default and only disabled explicitly.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import abc
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import random
|
|
26
|
+
import ssl
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
import urllib.error
|
|
30
|
+
import urllib.request
|
|
31
|
+
from collections import deque
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from spanforge.sdk._exceptions import (
|
|
37
|
+
SFAuthError,
|
|
38
|
+
SFRateLimitError,
|
|
39
|
+
SFServiceUnavailableError,
|
|
40
|
+
)
|
|
41
|
+
from spanforge.sdk._types import RateLimitInfo, SecretStr
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"SFClientConfig",
|
|
45
|
+
"SFServiceClient",
|
|
46
|
+
"_CircuitBreaker",
|
|
47
|
+
"_SlidingWindowRateLimiter",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
_log = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Circuit breaker
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
_CB_THRESHOLD_DEFAULT: int = 5
|
|
57
|
+
_CB_RESET_DEFAULT: float = 30.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _CircuitBreaker:
|
|
61
|
+
"""Thread-safe circuit breaker.
|
|
62
|
+
|
|
63
|
+
Transitions:
|
|
64
|
+
* CLOSED → OPEN after *threshold* consecutive failures.
|
|
65
|
+
* OPEN → CLOSED automatically after *reset_seconds* have elapsed.
|
|
66
|
+
* Any call to :meth:`record_success` while OPEN also resets to CLOSED.
|
|
67
|
+
|
|
68
|
+
This matches the pattern established in
|
|
69
|
+
:mod:`spanforge._batch_exporter`.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
CLOSED = "closed"
|
|
73
|
+
OPEN = "open"
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
threshold: int = _CB_THRESHOLD_DEFAULT,
|
|
78
|
+
reset_seconds: float = _CB_RESET_DEFAULT,
|
|
79
|
+
) -> None:
|
|
80
|
+
self._threshold = threshold
|
|
81
|
+
self._reset_seconds = reset_seconds
|
|
82
|
+
self._failures: int = 0
|
|
83
|
+
self._state: str = self.CLOSED
|
|
84
|
+
self._opened_at: float = 0.0
|
|
85
|
+
self._lock = threading.Lock()
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
# Public API
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def state(self) -> str:
|
|
93
|
+
"""Return the *current* state, auto-resetting if the reset window has elapsed."""
|
|
94
|
+
with self._lock:
|
|
95
|
+
return self._state_unlocked
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def _state_unlocked(self) -> str:
|
|
99
|
+
"""Inner state check; MUST be called with ``self._lock`` held."""
|
|
100
|
+
if self._state == self.OPEN and time.monotonic() - self._opened_at >= self._reset_seconds:
|
|
101
|
+
self._state = self.CLOSED
|
|
102
|
+
self._failures = 0
|
|
103
|
+
return self._state
|
|
104
|
+
|
|
105
|
+
def is_open(self) -> bool:
|
|
106
|
+
"""Return ``True`` when the circuit is open (requests should be blocked)."""
|
|
107
|
+
with self._lock:
|
|
108
|
+
return self._state_unlocked == self.OPEN
|
|
109
|
+
|
|
110
|
+
def record_success(self) -> None:
|
|
111
|
+
"""Reset failure counter and close the circuit."""
|
|
112
|
+
with self._lock:
|
|
113
|
+
self._failures = 0
|
|
114
|
+
self._state = self.CLOSED
|
|
115
|
+
|
|
116
|
+
def record_failure(self) -> None:
|
|
117
|
+
"""Increment failure counter and open the circuit if the threshold is reached."""
|
|
118
|
+
with self._lock:
|
|
119
|
+
self._failures += 1
|
|
120
|
+
if self._failures >= self._threshold:
|
|
121
|
+
self._state = self.OPEN
|
|
122
|
+
self._opened_at = time.monotonic()
|
|
123
|
+
|
|
124
|
+
def reset(self) -> None:
|
|
125
|
+
"""Forcibly reset the circuit to CLOSED with zero failures."""
|
|
126
|
+
with self._lock:
|
|
127
|
+
self._failures = 0
|
|
128
|
+
self._state = self.CLOSED
|
|
129
|
+
self._opened_at = 0.0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Sliding window rate limiter
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _SlidingWindowRateLimiter:
|
|
138
|
+
"""Thread-safe per-key sliding window rate limiter.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
limit: Maximum requests allowed per window.
|
|
142
|
+
window_seconds: Duration of the sliding window in seconds.
|
|
143
|
+
|
|
144
|
+
Example::
|
|
145
|
+
|
|
146
|
+
limiter = _SlidingWindowRateLimiter(limit=600, window_seconds=60)
|
|
147
|
+
ok = limiter.record("my_key") # True if within limit
|
|
148
|
+
info = limiter.check("my_key") # inspect without counting
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(
|
|
152
|
+
self,
|
|
153
|
+
limit: int = 600,
|
|
154
|
+
window_seconds: float = 60.0,
|
|
155
|
+
) -> None:
|
|
156
|
+
if limit < 1:
|
|
157
|
+
raise ValueError("limit must be >= 1")
|
|
158
|
+
if window_seconds <= 0:
|
|
159
|
+
raise ValueError("window_seconds must be > 0")
|
|
160
|
+
self._limit = limit
|
|
161
|
+
self._window = window_seconds
|
|
162
|
+
self._windows: dict[str, deque[float]] = {}
|
|
163
|
+
self._lock = threading.Lock()
|
|
164
|
+
|
|
165
|
+
def _evict(self, timestamps: deque[float], now: float) -> None:
|
|
166
|
+
"""Remove timestamps outside the current window. NOT thread-safe alone."""
|
|
167
|
+
while timestamps and now - timestamps[0] >= self._window:
|
|
168
|
+
timestamps.popleft()
|
|
169
|
+
|
|
170
|
+
def check(self, key_id: str) -> RateLimitInfo:
|
|
171
|
+
"""Return rate-limit state without counting the call as a request."""
|
|
172
|
+
with self._lock:
|
|
173
|
+
now = time.monotonic()
|
|
174
|
+
timestamps = self._windows.setdefault(key_id, deque())
|
|
175
|
+
self._evict(timestamps, now)
|
|
176
|
+
remaining = max(0, self._limit - len(timestamps))
|
|
177
|
+
reset_at = datetime.now(timezone.utc)
|
|
178
|
+
return RateLimitInfo(
|
|
179
|
+
limit=self._limit,
|
|
180
|
+
remaining=remaining,
|
|
181
|
+
reset_at=reset_at,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def record(self, key_id: str) -> bool:
|
|
185
|
+
"""Count a request.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
``True`` if the request is within the limit (allowed).
|
|
189
|
+
``False`` if the limit has been reached (caller should 429).
|
|
190
|
+
"""
|
|
191
|
+
with self._lock:
|
|
192
|
+
now = time.monotonic()
|
|
193
|
+
timestamps = self._windows.setdefault(key_id, deque())
|
|
194
|
+
self._evict(timestamps, now)
|
|
195
|
+
if len(timestamps) >= self._limit:
|
|
196
|
+
return False
|
|
197
|
+
timestamps.append(now)
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
def remaining(self, key_id: str) -> int:
|
|
201
|
+
"""Return remaining requests in the current window without counting."""
|
|
202
|
+
info = self.check(key_id)
|
|
203
|
+
return info.remaining
|
|
204
|
+
|
|
205
|
+
def clear(self, key_id: str) -> None:
|
|
206
|
+
"""Remove all recorded timestamps for *key_id* (e.g. for testing)."""
|
|
207
|
+
with self._lock:
|
|
208
|
+
self._windows.pop(key_id, None)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# Token-expiry warning threshold (seconds)
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
#: Warn / trigger refresh when token TTL drops below this many seconds.
|
|
216
|
+
_TOKEN_EXPIRY_WARN_SECS: int = 60
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# Known SPANFORGE_ environment variable names
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
_KNOWN_SPANFORGE_VARS: frozenset[str] = frozenset(
|
|
223
|
+
{
|
|
224
|
+
"SPANFORGE_ENDPOINT",
|
|
225
|
+
"SPANFORGE_API_KEY",
|
|
226
|
+
"SPANFORGE_PROJECT_ID",
|
|
227
|
+
"SPANFORGE_TIMEOUT_MS",
|
|
228
|
+
"SPANFORGE_MAX_RETRIES",
|
|
229
|
+
"SPANFORGE_LOCAL_FALLBACK",
|
|
230
|
+
"SPANFORGE_TLS_VERIFY",
|
|
231
|
+
"SPANFORGE_PROXY",
|
|
232
|
+
"SPANFORGE_SIGNING_KEY",
|
|
233
|
+
"SPANFORGE_MAGIC_SECRET",
|
|
234
|
+
# Phase 9 — Integration Config & Local Fallback (CFG-006)
|
|
235
|
+
"SPANFORGE_PII_THRESHOLD",
|
|
236
|
+
"SPANFORGE_SECRETS_AUTO_BLOCK",
|
|
237
|
+
"SPANFORGE_FALLBACK_MAX_RETRIES",
|
|
238
|
+
"SPANFORGE_FALLBACK_TIMEOUT_MS",
|
|
239
|
+
"SPANFORGE_LOCAL_TOKEN",
|
|
240
|
+
# Phase 8 — CI/CD Gate Pipeline
|
|
241
|
+
"SPANFORGE_GATE_ARTIFACT_DIR",
|
|
242
|
+
"SPANFORGE_GATE_ARTIFACT_RETENTION_DAYS",
|
|
243
|
+
"SPANFORGE_GATE_PRRI_RED_THRESHOLD",
|
|
244
|
+
"SPANFORGE_GATE_HRI_CRITICAL_THRESHOLD",
|
|
245
|
+
"SPANFORGE_GATE_PII_WINDOW_HOURS",
|
|
246
|
+
"SPANFORGE_GATE_SECRETS_WINDOW_HOURS",
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
_cfg_log = logging.getLogger(__name__)
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# Client configuration
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass
|
|
258
|
+
class SFClientConfig:
|
|
259
|
+
"""Configuration for a SpanForge service client.
|
|
260
|
+
|
|
261
|
+
All fields have sensible defaults. When ``endpoint`` is empty the client
|
|
262
|
+
operates in *local mode* — all logic is executed in-process with no
|
|
263
|
+
network calls.
|
|
264
|
+
|
|
265
|
+
Attributes:
|
|
266
|
+
endpoint: Base URL of the SpanForge service
|
|
267
|
+
(e.g. ``"https://api.spanforge.dev"``).
|
|
268
|
+
**Empty string** enables local/fallback mode.
|
|
269
|
+
api_key: SpanForge API key. Must be :class:`~spanforge.sdk._types.SecretStr`.
|
|
270
|
+
project_id: Default project scope for new keys.
|
|
271
|
+
timeout_ms: HTTP timeout in milliseconds (default: 2 000 ms).
|
|
272
|
+
max_retries: Number of additional attempts after the first failure
|
|
273
|
+
(default: 3; so 4 total attempts).
|
|
274
|
+
local_fallback_enabled: If ``True`` (default), fall back to local
|
|
275
|
+
logic when the remote service is unreachable.
|
|
276
|
+
tls_verify: Verify TLS certificates (default: ``True``).
|
|
277
|
+
**Never set to ``False`` in production.**
|
|
278
|
+
proxy: Optional HTTP/HTTPS proxy URL.
|
|
279
|
+
signing_key: Secret used for local HS256 JWT signing. Loaded from
|
|
280
|
+
``SPANFORGE_SIGNING_KEY`` env var if not set here.
|
|
281
|
+
magic_secret: Secret for HMAC magic-link tokens. Loaded from
|
|
282
|
+
``SPANFORGE_MAGIC_SECRET`` env var if not set here.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
endpoint: str = ""
|
|
286
|
+
api_key: SecretStr = field(default_factory=lambda: SecretStr(""))
|
|
287
|
+
project_id: str = ""
|
|
288
|
+
timeout_ms: int = 2_000
|
|
289
|
+
max_retries: int = 3
|
|
290
|
+
local_fallback_enabled: bool = True
|
|
291
|
+
tls_verify: bool = True
|
|
292
|
+
proxy: str | None = None
|
|
293
|
+
signing_key: str = ""
|
|
294
|
+
magic_secret: str = ""
|
|
295
|
+
|
|
296
|
+
@classmethod
|
|
297
|
+
def from_env(cls) -> SFClientConfig:
|
|
298
|
+
"""Build a config from environment variables.
|
|
299
|
+
|
|
300
|
+
Variable mapping::
|
|
301
|
+
|
|
302
|
+
SPANFORGE_ENDPOINT → endpoint
|
|
303
|
+
SPANFORGE_API_KEY → api_key
|
|
304
|
+
SPANFORGE_PROJECT_ID → project_id
|
|
305
|
+
SPANFORGE_TIMEOUT_MS → timeout_ms
|
|
306
|
+
SPANFORGE_MAX_RETRIES → max_retries
|
|
307
|
+
SPANFORGE_LOCAL_FALLBACK → local_fallback_enabled
|
|
308
|
+
SPANFORGE_TLS_VERIFY → tls_verify
|
|
309
|
+
SPANFORGE_PROXY → proxy
|
|
310
|
+
SPANFORGE_SIGNING_KEY → signing_key
|
|
311
|
+
SPANFORGE_MAGIC_SECRET → magic_secret
|
|
312
|
+
"""
|
|
313
|
+
raw_fallback = os.environ.get("SPANFORGE_LOCAL_FALLBACK", "true").lower()
|
|
314
|
+
local_fallback = raw_fallback not in ("false", "0", "no")
|
|
315
|
+
|
|
316
|
+
raw_tls = os.environ.get("SPANFORGE_TLS_VERIFY", "true").lower()
|
|
317
|
+
tls_verify = raw_tls not in ("false", "0", "no")
|
|
318
|
+
|
|
319
|
+
# ID-005: Warn on unknown SPANFORGE_* env vars
|
|
320
|
+
for env_key in os.environ:
|
|
321
|
+
if env_key.startswith("SPANFORGE_") and env_key not in _KNOWN_SPANFORGE_VARS:
|
|
322
|
+
_cfg_log.warning(
|
|
323
|
+
"Unknown SPANFORGE_ environment variable: %r — this variable "
|
|
324
|
+
"is not recognised by the SpanForge SDK and will be ignored.",
|
|
325
|
+
env_key,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
return cls(
|
|
329
|
+
endpoint=os.environ.get("SPANFORGE_ENDPOINT", ""),
|
|
330
|
+
api_key=SecretStr(os.environ.get("SPANFORGE_API_KEY", "")),
|
|
331
|
+
project_id=os.environ.get("SPANFORGE_PROJECT_ID", ""),
|
|
332
|
+
timeout_ms=int(os.environ.get("SPANFORGE_TIMEOUT_MS", "2000")),
|
|
333
|
+
max_retries=int(os.environ.get("SPANFORGE_MAX_RETRIES", "3")),
|
|
334
|
+
local_fallback_enabled=local_fallback,
|
|
335
|
+
tls_verify=tls_verify,
|
|
336
|
+
proxy=os.environ.get("SPANFORGE_PROXY"),
|
|
337
|
+
signing_key=os.environ.get("SPANFORGE_SIGNING_KEY", ""),
|
|
338
|
+
magic_secret=os.environ.get("SPANFORGE_MAGIC_SECRET", ""),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
# Abstract service client base
|
|
344
|
+
# ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
_HTTP_429: int = 429
|
|
348
|
+
_HTTP_401_403: frozenset[int] = frozenset({401, 403})
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class SFServiceClient(abc.ABC):
|
|
352
|
+
"""Abstract base class for SpanForge service clients.
|
|
353
|
+
|
|
354
|
+
Provides:
|
|
355
|
+
* Circuit breaker (5-failure threshold, 30 s reset).
|
|
356
|
+
* Retry with exponential back-off and random jitter.
|
|
357
|
+
* Structured error translation (429 → :exc:`~spanforge.sdk._exceptions.SFRateLimitError`,
|
|
358
|
+
401/403 → :exc:`~spanforge.sdk._exceptions.SFAuthError`).
|
|
359
|
+
* Local-mode detection via :meth:`_is_local_mode`.
|
|
360
|
+
|
|
361
|
+
Concrete subclasses implement service-specific methods; they call
|
|
362
|
+
:meth:`_request` for remote operations and fall back to local in-process
|
|
363
|
+
logic when :meth:`_is_local_mode` returns ``True``.
|
|
364
|
+
"""
|
|
365
|
+
|
|
366
|
+
def __init__(
|
|
367
|
+
self,
|
|
368
|
+
config: SFClientConfig,
|
|
369
|
+
service_name: str,
|
|
370
|
+
) -> None:
|
|
371
|
+
self._config = config
|
|
372
|
+
self._service_name = service_name
|
|
373
|
+
self._circuit_breaker = _CircuitBreaker(
|
|
374
|
+
threshold=_CB_THRESHOLD_DEFAULT,
|
|
375
|
+
reset_seconds=_CB_RESET_DEFAULT,
|
|
376
|
+
)
|
|
377
|
+
# Install proxy handler if configured
|
|
378
|
+
if config.proxy:
|
|
379
|
+
proxy_handler = urllib.request.ProxyHandler(
|
|
380
|
+
{"http": config.proxy, "https": config.proxy}
|
|
381
|
+
)
|
|
382
|
+
opener = urllib.request.build_opener(proxy_handler)
|
|
383
|
+
urllib.request.install_opener(opener)
|
|
384
|
+
|
|
385
|
+
# ------------------------------------------------------------------
|
|
386
|
+
# ID-003: Token refresh hook (subclasses override)
|
|
387
|
+
# ------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
def _on_token_near_expiry(self, seconds_remaining: int) -> None:
|
|
390
|
+
"""Called when the auth token is about to expire.
|
|
391
|
+
|
|
392
|
+
Triggered when the ``X-SF-Token-Expires`` response header reports fewer
|
|
393
|
+
than ``_TOKEN_EXPIRY_WARN_SECS`` seconds until expiry. The default
|
|
394
|
+
implementation emits a warning log. :class:`SFIdentityClient` overrides
|
|
395
|
+
this to perform an inline token refresh (ID-003).
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
seconds_remaining: Seconds until token expiry per the response header.
|
|
399
|
+
"""
|
|
400
|
+
_log.warning(
|
|
401
|
+
"sf-%s auth token expires in %ds; consider calling refresh_token()",
|
|
402
|
+
self._service_name,
|
|
403
|
+
seconds_remaining,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# ------------------------------------------------------------------
|
|
407
|
+
# Helpers
|
|
408
|
+
# ------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
def _is_local_mode(self) -> bool:
|
|
411
|
+
"""Return ``True`` when no service endpoint is configured."""
|
|
412
|
+
return not self._config.endpoint.strip()
|
|
413
|
+
|
|
414
|
+
def _is_sandbox(self) -> bool:
|
|
415
|
+
"""Return ``True`` when sandbox mode is enabled via config or env."""
|
|
416
|
+
import os
|
|
417
|
+
|
|
418
|
+
if os.environ.get("SPANFORGE_SANDBOX", "").lower() in ("1", "true", "yes"):
|
|
419
|
+
return True
|
|
420
|
+
try:
|
|
421
|
+
from spanforge.sdk.config import load_config_file
|
|
422
|
+
|
|
423
|
+
cfg = load_config_file()
|
|
424
|
+
return cfg.sandbox
|
|
425
|
+
except Exception:
|
|
426
|
+
return False
|
|
427
|
+
|
|
428
|
+
def _build_opener(self) -> urllib.request.OpenerDirector:
|
|
429
|
+
"""Build a URL opener, optionally with proxy support."""
|
|
430
|
+
handlers: list[urllib.request.BaseHandler] = []
|
|
431
|
+
if not self._config.tls_verify:
|
|
432
|
+
ctx = ssl.create_default_context()
|
|
433
|
+
ctx.check_hostname = False
|
|
434
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
435
|
+
handlers.append(urllib.request.HTTPSHandler(context=ctx))
|
|
436
|
+
if self._config.proxy:
|
|
437
|
+
handlers.append(
|
|
438
|
+
urllib.request.ProxyHandler(
|
|
439
|
+
{"http": self._config.proxy, "https": self._config.proxy}
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
return urllib.request.build_opener(*handlers) if handlers else urllib.request.build_opener()
|
|
443
|
+
|
|
444
|
+
def _request(
|
|
445
|
+
self,
|
|
446
|
+
method: str,
|
|
447
|
+
path: str,
|
|
448
|
+
body: dict[str, Any] | None = None,
|
|
449
|
+
) -> dict[str, Any]:
|
|
450
|
+
"""Make an authenticated JSON request to the remote service.
|
|
451
|
+
|
|
452
|
+
Behaviour:
|
|
453
|
+
* If the circuit breaker is OPEN, raises
|
|
454
|
+
:exc:`~spanforge.sdk._exceptions.SFServiceUnavailableError`
|
|
455
|
+
immediately without making a network call.
|
|
456
|
+
* On ``429`` responses, raises
|
|
457
|
+
:exc:`~spanforge.sdk._exceptions.SFRateLimitError` with
|
|
458
|
+
``Retry-After`` seconds.
|
|
459
|
+
* On ``401``/``403`` responses, raises
|
|
460
|
+
:exc:`~spanforge.sdk._exceptions.SFAuthError`.
|
|
461
|
+
* On other failures, retries up to ``config.max_retries`` times with
|
|
462
|
+
exponential back-off + jitter, then raises
|
|
463
|
+
:exc:`~spanforge.sdk._exceptions.SFServiceUnavailableError` (if
|
|
464
|
+
``local_fallback_enabled=False``) or re-raises the last exception.
|
|
465
|
+
|
|
466
|
+
Security:
|
|
467
|
+
* The ``X-SF-API-Key`` header carries the raw key value only. The
|
|
468
|
+
value is never logged here.
|
|
469
|
+
* Request bodies are JSON-serialised; no sensitive fields should
|
|
470
|
+
appear in ``body`` (callers are responsible for that).
|
|
471
|
+
"""
|
|
472
|
+
if self._circuit_breaker.is_open():
|
|
473
|
+
raise SFServiceUnavailableError(self._service_name)
|
|
474
|
+
|
|
475
|
+
url = f"{self._config.endpoint.rstrip('/')}{path}"
|
|
476
|
+
api_key_value = self._config.api_key.get_secret_value()
|
|
477
|
+
headers: dict[str, str] = {
|
|
478
|
+
"Content-Type": "application/json",
|
|
479
|
+
"Accept": "application/json",
|
|
480
|
+
"X-SF-API-Key": api_key_value,
|
|
481
|
+
}
|
|
482
|
+
encoded_body: bytes | None = (
|
|
483
|
+
json.dumps(body, separators=(",", ":")).encode() if body else None
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
last_exc: Exception | None = None
|
|
487
|
+
total_attempts = self._config.max_retries + 1
|
|
488
|
+
|
|
489
|
+
opener = self._build_opener()
|
|
490
|
+
|
|
491
|
+
for attempt in range(total_attempts):
|
|
492
|
+
if attempt > 0:
|
|
493
|
+
# Exponential back-off: 0.5, 1.0, 2.0, … up to 10 s + jitter
|
|
494
|
+
delay = min(0.5 * (2**attempt), 10.0) + random.uniform(0.0, 0.1) # nosec B311 -- timing jitter only, not crypto
|
|
495
|
+
time.sleep(delay)
|
|
496
|
+
|
|
497
|
+
try:
|
|
498
|
+
req = urllib.request.Request(
|
|
499
|
+
url,
|
|
500
|
+
data=encoded_body,
|
|
501
|
+
headers=headers,
|
|
502
|
+
method=method.upper(),
|
|
503
|
+
)
|
|
504
|
+
timeout_s = self._config.timeout_ms / 1_000.0
|
|
505
|
+
with opener.open(req, timeout=timeout_s) as resp:
|
|
506
|
+
# ID-003: Check token expiry header and call refresh hook
|
|
507
|
+
token_expires_header = resp.headers.get("X-SF-Token-Expires")
|
|
508
|
+
if token_expires_header:
|
|
509
|
+
try:
|
|
510
|
+
token_ttl = int(token_expires_header)
|
|
511
|
+
if token_ttl < _TOKEN_EXPIRY_WARN_SECS:
|
|
512
|
+
self._on_token_near_expiry(token_ttl)
|
|
513
|
+
except (ValueError, TypeError):
|
|
514
|
+
pass
|
|
515
|
+
raw = resp.read()
|
|
516
|
+
self._circuit_breaker.record_success()
|
|
517
|
+
return json.loads(raw) if raw else {}
|
|
518
|
+
|
|
519
|
+
except urllib.error.HTTPError as exc:
|
|
520
|
+
if exc.code == _HTTP_429:
|
|
521
|
+
retry_after = int(exc.headers.get("Retry-After", "60"))
|
|
522
|
+
raise SFRateLimitError(retry_after) from exc
|
|
523
|
+
if exc.code in _HTTP_401_403:
|
|
524
|
+
raise SFAuthError(f"HTTP {exc.code} from sf-{self._service_name}") from exc
|
|
525
|
+
_log.debug(
|
|
526
|
+
"sf-%s request failed (attempt %d/%d): HTTP %s",
|
|
527
|
+
self._service_name,
|
|
528
|
+
attempt + 1,
|
|
529
|
+
total_attempts,
|
|
530
|
+
exc.code,
|
|
531
|
+
)
|
|
532
|
+
self._circuit_breaker.record_failure()
|
|
533
|
+
last_exc = exc
|
|
534
|
+
|
|
535
|
+
except (urllib.error.URLError, OSError, TimeoutError) as exc:
|
|
536
|
+
_log.debug(
|
|
537
|
+
"sf-%s request failed (attempt %d/%d): %s",
|
|
538
|
+
self._service_name,
|
|
539
|
+
attempt + 1,
|
|
540
|
+
total_attempts,
|
|
541
|
+
type(exc).__name__,
|
|
542
|
+
)
|
|
543
|
+
self._circuit_breaker.record_failure()
|
|
544
|
+
last_exc = exc
|
|
545
|
+
|
|
546
|
+
# All retries exhausted
|
|
547
|
+
if not self._config.local_fallback_enabled:
|
|
548
|
+
raise SFServiceUnavailableError(self._service_name)
|
|
549
|
+
|
|
550
|
+
# Caller should handle the fallback in local mode
|
|
551
|
+
_log.warning(
|
|
552
|
+
"sf-%s unreachable after %d attempt(s); falling back to local mode",
|
|
553
|
+
self._service_name,
|
|
554
|
+
total_attempts,
|
|
555
|
+
)
|
|
556
|
+
if last_exc is not None:
|
|
557
|
+
raise last_exc
|
|
558
|
+
raise SFServiceUnavailableError(self._service_name) # pragma: no cover
|
|
559
|
+
|
|
560
|
+
async def _request_async(
|
|
561
|
+
self,
|
|
562
|
+
method: str,
|
|
563
|
+
path: str,
|
|
564
|
+
body: dict[str, Any] | None = None,
|
|
565
|
+
) -> dict[str, Any]:
|
|
566
|
+
"""Async wrapper around :meth:`_request`.
|
|
567
|
+
|
|
568
|
+
Runs the blocking :meth:`_request` in the default
|
|
569
|
+
:class:`~concurrent.futures.ThreadPoolExecutor` so the event loop is
|
|
570
|
+
not blocked. This enables callers to ``await`` SDK calls in async
|
|
571
|
+
application contexts without any additional dependencies.
|
|
572
|
+
|
|
573
|
+
Args:
|
|
574
|
+
method: HTTP method (e.g. ``"GET"``, ``"POST"``).
|
|
575
|
+
path: URL path component (e.g. ``"/v1/secrets/scan"``).
|
|
576
|
+
body: Optional JSON-serialisable request body.
|
|
577
|
+
|
|
578
|
+
Returns:
|
|
579
|
+
Parsed JSON response dict, or ``{}`` on empty body.
|
|
580
|
+
"""
|
|
581
|
+
import asyncio
|
|
582
|
+
|
|
583
|
+
loop = asyncio.get_event_loop()
|
|
584
|
+
return await loop.run_in_executor(None, self._request, method, path, body)
|
spanforge/sdk/_base.pyi
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Type stubs for spanforge.sdk._base (DX-001)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import abc
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from spanforge.sdk._types import RateLimitInfo, SecretStr
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"SFClientConfig",
|
|
13
|
+
"SFServiceClient",
|
|
14
|
+
"_CircuitBreaker",
|
|
15
|
+
"_SlidingWindowRateLimiter",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
class _CircuitBreaker:
|
|
19
|
+
CLOSED: str
|
|
20
|
+
OPEN: str
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
threshold: int = 5,
|
|
24
|
+
reset_seconds: float = 30.0,
|
|
25
|
+
) -> None: ...
|
|
26
|
+
@property
|
|
27
|
+
def state(self) -> str: ...
|
|
28
|
+
def is_open(self) -> bool: ...
|
|
29
|
+
def record_success(self) -> None: ...
|
|
30
|
+
def record_failure(self) -> None: ...
|
|
31
|
+
def reset(self) -> None: ...
|
|
32
|
+
|
|
33
|
+
class _SlidingWindowRateLimiter:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
limit: int = 600,
|
|
37
|
+
window_seconds: float = 60.0,
|
|
38
|
+
) -> None: ...
|
|
39
|
+
def check(self, key_id: str) -> RateLimitInfo: ...
|
|
40
|
+
def record(self, key_id: str) -> bool: ...
|
|
41
|
+
def remaining(self, key_id: str) -> int: ...
|
|
42
|
+
def clear(self, key_id: str) -> None: ...
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class SFClientConfig:
|
|
46
|
+
endpoint: str = ""
|
|
47
|
+
api_key: SecretStr = ...
|
|
48
|
+
project_id: str = ""
|
|
49
|
+
timeout_ms: int = 2_000
|
|
50
|
+
max_retries: int = 3
|
|
51
|
+
local_fallback_enabled: bool = True
|
|
52
|
+
tls_verify: bool = True
|
|
53
|
+
proxy: str | None = None
|
|
54
|
+
signing_key: str = ""
|
|
55
|
+
magic_secret: str = ""
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_env(cls) -> SFClientConfig: ...
|
|
58
|
+
|
|
59
|
+
class SFServiceClient(abc.ABC):
|
|
60
|
+
_config: SFClientConfig
|
|
61
|
+
_service_name: str
|
|
62
|
+
_circuit_breaker: _CircuitBreaker
|
|
63
|
+
def __init__(self, config: SFClientConfig, service_name: str) -> None: ...
|
|
64
|
+
def _is_local_mode(self) -> bool: ...
|
|
65
|
+
def _request(
|
|
66
|
+
self,
|
|
67
|
+
method: str,
|
|
68
|
+
path: str,
|
|
69
|
+
body: dict[str, Any] | None = None,
|
|
70
|
+
) -> dict[str, Any]: ...
|
|
71
|
+
def _on_token_near_expiry(self, seconds_remaining: int) -> None: ...
|