santati 0.2.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.
Files changed (41) hide show
  1. santati/__init__.py +79 -0
  2. santati/_client.py +522 -0
  3. santati/_errors.py +68 -0
  4. santati/_outbox.py +278 -0
  5. santati/_retry.py +67 -0
  6. santati/_types.py +94 -0
  7. santati/integrations/__init__.py +41 -0
  8. santati/integrations/_core.py +247 -0
  9. santati/integrations/claude_agent_sdk.py +82 -0
  10. santati/integrations/django.py +258 -0
  11. santati/integrations/langchain.py +165 -0
  12. santati/integrations/openai_agents.py +112 -0
  13. santati/integrations/pydantic_ai.py +92 -0
  14. santati/outbox/__init__.py +12 -0
  15. santati/outbox/redis.py +123 -0
  16. santati/py.typed +0 -0
  17. santati-0.2.0.dist-info/METADATA +213 -0
  18. santati-0.2.0.dist-info/RECORD +41 -0
  19. santati-0.2.0.dist-info/WHEEL +4 -0
  20. santati-0.2.0.dist-info/licenses/LICENSE +190 -0
  21. santati_core/__init__.py +72 -0
  22. santati_core/api/__init__.py +5 -0
  23. santati_core/api/audit_events_api.py +843 -0
  24. santati_core/api_client.py +833 -0
  25. santati_core/api_response.py +21 -0
  26. santati_core/configuration.py +679 -0
  27. santati_core/exceptions.py +218 -0
  28. santati_core/models/__init__.py +28 -0
  29. santati_core/models/audit_event.py +128 -0
  30. santati_core/models/error_body.py +97 -0
  31. santati_core/models/event_actor.py +94 -0
  32. santati_core/models/event_actor_request.py +94 -0
  33. santati_core/models/event_batch_item_result.py +105 -0
  34. santati_core/models/event_batch_request.py +95 -0
  35. santati_core/models/event_batch_result.py +99 -0
  36. santati_core/models/event_envelope_request.py +122 -0
  37. santati_core/models/event_ingest_request.py +137 -0
  38. santati_core/models/event_target.py +94 -0
  39. santati_core/models/event_target_request.py +94 -0
  40. santati_core/models/paginated_audit_event_list.py +109 -0
  41. santati_core/rest.py +334 -0
santati/__init__.py ADDED
@@ -0,0 +1,79 @@
1
+ """Official Python SDK for the Santati audit-log API.
2
+
3
+ import santati
4
+
5
+ with santati.Santati("sat_sk_...", trail="billing") as client:
6
+ result = client.events.emit("invoice.voided", organization_id="org_acme")
7
+ print(result.event.id, result.duplicate)
8
+
9
+ # With an outbox, emit returns at once and a background worker sends the event.
10
+ with santati.Santati("sat_sk_...", trail="billing", outbox=santati.MemoryOutbox()) as client:
11
+ client.events.emit("invoice.paid", organization_id="org_acme")
12
+
13
+ See ``docs/sdk-surface.md`` in the repository for the surface every Santati SDK
14
+ implements.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from santati_core.models.audit_event import AuditEvent
20
+ from santati_core.models.event_actor import EventActor
21
+ from santati_core.models.event_target import EventTarget
22
+
23
+ from ._client import DEFAULT_BASE_URL, Events, Santati
24
+ from ._client import __version__ as __version__
25
+ from ._errors import (
26
+ ApiError,
27
+ AuthError,
28
+ NotFoundError,
29
+ OutboxError,
30
+ RateLimitedError,
31
+ SantatiError,
32
+ ServerError,
33
+ TransportError,
34
+ ValidationError,
35
+ )
36
+ from ._outbox import MemoryOutbox, OutboxEntry, OutboxStore, PostSendHook, PreSendHook, SendOutcome
37
+ from ._types import (
38
+ ActorInput,
39
+ BatchItem,
40
+ BatchItemError,
41
+ BatchResult,
42
+ EmitResult,
43
+ EventInput,
44
+ EventPage,
45
+ TargetInput,
46
+ )
47
+
48
+ __all__ = [
49
+ "DEFAULT_BASE_URL",
50
+ "ActorInput",
51
+ "ApiError",
52
+ "AuditEvent",
53
+ "AuthError",
54
+ "BatchItem",
55
+ "BatchItemError",
56
+ "BatchResult",
57
+ "EmitResult",
58
+ "EventActor",
59
+ "EventInput",
60
+ "EventPage",
61
+ "EventTarget",
62
+ "Events",
63
+ "MemoryOutbox",
64
+ "NotFoundError",
65
+ "OutboxEntry",
66
+ "OutboxError",
67
+ "OutboxStore",
68
+ "PostSendHook",
69
+ "PreSendHook",
70
+ "RateLimitedError",
71
+ "Santati",
72
+ "SantatiError",
73
+ "SendOutcome",
74
+ "ServerError",
75
+ "TargetInput",
76
+ "TransportError",
77
+ "ValidationError",
78
+ "__version__",
79
+ ]
santati/_client.py ADDED
@@ -0,0 +1,522 @@
1
+ """The Santati client and its ``events`` resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+ import json
7
+ import re
8
+ import urllib.parse
9
+ import uuid
10
+ from collections.abc import Callable, Iterator, Mapping, Sequence
11
+ from types import TracebackType
12
+ from typing import Any, TypeVar, cast
13
+
14
+ import urllib3
15
+ from pydantic import BaseModel
16
+ from pydantic import ValidationError as PydanticValidationError
17
+ from typing_extensions import Self
18
+
19
+ from santati_core import ApiClient, AuditEventsApi, Configuration
20
+ from santati_core.models.audit_event import AuditEvent
21
+ from santati_core.models.event_batch_item_result import EventBatchItemResult
22
+ from santati_core.models.event_batch_request import EventBatchRequest
23
+ from santati_core.models.event_batch_result import EventBatchResult
24
+ from santati_core.models.event_envelope_request import EventEnvelopeRequest
25
+ from santati_core.models.event_ingest_request import EventIngestRequest
26
+ from santati_core.models.paginated_audit_event_list import PaginatedAuditEventList
27
+
28
+ from ._errors import (
29
+ ApiError,
30
+ AuthError,
31
+ NotFoundError,
32
+ RateLimitedError,
33
+ SantatiError,
34
+ ServerError,
35
+ TransportError,
36
+ ValidationError,
37
+ )
38
+ from ._outbox import OutboxStore, PostSendHook, PreSendHook, _Outbox
39
+ from ._retry import RetryPolicy
40
+ from ._types import (
41
+ ActorInput,
42
+ BatchItem,
43
+ BatchItemError,
44
+ BatchResult,
45
+ EmitResult,
46
+ EventInput,
47
+ EventPage,
48
+ TargetInput,
49
+ )
50
+
51
+ __version__: str = importlib.metadata.version("santati")
52
+ """This SDK's version, read from the installed distribution metadata."""
53
+
54
+ DEFAULT_BASE_URL = "https://api.santati.io"
55
+ """Where requests go when no ``base_url`` is given."""
56
+
57
+ _MODEL = TypeVar("_MODEL", bound=BaseModel)
58
+
59
+
60
+ class Santati:
61
+ """A Santati client.
62
+
63
+ Construction is the only place the options are read; :attr:`trail`, the
64
+ default trail for emits, stays live. Use it as a context manager, or call
65
+ :meth:`close` yourself, to send what a queued :meth:`Events.emit` left in the
66
+ outbox and release the pooled connections.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ api_key: str,
72
+ *,
73
+ base_url: str = DEFAULT_BASE_URL,
74
+ trail: str | None = None,
75
+ timeout_ms: int = 10000,
76
+ max_retries: int = 2,
77
+ initial_backoff_ms: int = 250,
78
+ max_backoff_ms: int = 8000,
79
+ headers: Mapping[str, str] | None = None,
80
+ outbox: OutboxStore | None = None,
81
+ batch_size: int = 100,
82
+ flush_interval_ms: int = 1000,
83
+ pre_send: PreSendHook | None = None,
84
+ post_send: PostSendHook | None = None,
85
+ ) -> None:
86
+ if not api_key:
87
+ raise ValidationError("api_key must be a non-empty string", field="api_key")
88
+ for name in headers or {}:
89
+ if name.lower() == "authorization":
90
+ raise ValidationError(
91
+ "headers must not set Authorization: the SDK sets it on every request",
92
+ field="headers",
93
+ )
94
+ if not 1 <= batch_size <= 500:
95
+ raise ValidationError("batch_size must be between 1 and 500", field="batch_size")
96
+ if flush_interval_ms <= 0:
97
+ raise ValidationError("flush_interval_ms must be above zero", field="flush_interval_ms")
98
+
99
+ self.api_key = api_key
100
+ self.base_url = base_url.rstrip("/")
101
+ self.trail = trail
102
+ self.headers = dict(headers or {})
103
+
104
+ self._timeout_seconds = timeout_ms / 1000
105
+ self._retry = RetryPolicy(max_retries, initial_backoff_ms, max_backoff_ms)
106
+ # The generated core applies the spec's `ApiKeyAuth` scheme itself: it
107
+ # reads the key and the `Api-Key` prefix from the configuration.
108
+ self._api_client = ApiClient(
109
+ Configuration(
110
+ host=self.base_url,
111
+ api_key={"ApiKeyAuth": api_key},
112
+ api_key_prefix={"ApiKeyAuth": "Api-Key"},
113
+ retries=urllib3.util.Retry(total=0, redirect=False),
114
+ )
115
+ )
116
+ self._api_client.user_agent = f"santati-python/{__version__}"
117
+ _apply_default_headers(self._api_client, self.headers)
118
+ self._api = AuditEventsApi(self._api_client)
119
+
120
+ self.events = Events(self)
121
+ """The audit-event operations: ``emit``, ``emit_batch``, ``list``, ``iterate``."""
122
+
123
+ self._outbox: _Outbox | None = (
124
+ None
125
+ if outbox is None
126
+ else _Outbox(
127
+ self,
128
+ outbox,
129
+ batch_size=batch_size,
130
+ flush_interval_ms=flush_interval_ms,
131
+ pre_send=pre_send,
132
+ post_send=post_send,
133
+ )
134
+ )
135
+
136
+ def flush(self) -> None:
137
+ """Send what the outbox holds now: one pass, in batches of ``batch_size``.
138
+
139
+ Returns at once when the client has no ``outbox``.
140
+ """
141
+ if self._outbox is not None:
142
+ self._outbox.flush()
143
+
144
+ def close(self) -> None:
145
+ """Stop the outbox worker (if any), flush the outbox once, and release the pooled connections.
146
+
147
+ A queued :meth:`Events.emit` after ``close`` raises :class:`OutboxError`
148
+ (``closed``); without an ``outbox`` only the connections are released
149
+ and ``emit`` keeps working.
150
+ """
151
+ if self._outbox is not None:
152
+ self._outbox.close()
153
+ self._api_client.rest_client.pool_manager.clear()
154
+
155
+ def __enter__(self) -> Self:
156
+ return self
157
+
158
+ def __exit__(
159
+ self,
160
+ exc_type: type[BaseException] | None,
161
+ exc_value: BaseException | None,
162
+ traceback: TracebackType | None,
163
+ ) -> None:
164
+ self.close()
165
+
166
+
167
+ class Events:
168
+ """Audit-event operations, reached through ``client.events``."""
169
+
170
+ def __init__(self, client: Santati) -> None:
171
+ self._client = client
172
+
173
+ def emit(
174
+ self,
175
+ event: str,
176
+ *,
177
+ trail: str | None = None,
178
+ organization_id: str | None = None,
179
+ actor: ActorInput | None = None,
180
+ targets: Sequence[TargetInput] | None = None,
181
+ metadata: Mapping[str, str] | None = None,
182
+ data: Any = None,
183
+ context: Mapping[str, Any] | None = None,
184
+ created_at: str | None = None,
185
+ idempotency_key: str | None = None,
186
+ ) -> EmitResult:
187
+ """Index one event, or with an ``outbox`` store it for the background worker and return at once.
188
+
189
+ A repeated ``idempotency_key`` returns the stored event. A queued emit
190
+ never makes a request: the result has ``event=None`` and ``queued=True``.
191
+ Raises :class:`OutboxError` when the store refuses the event or the
192
+ client is closed.
193
+ """
194
+ envelope = _build_envelope(
195
+ event=event,
196
+ trail=trail,
197
+ organization_id=organization_id,
198
+ actor=actor,
199
+ targets=targets,
200
+ metadata=metadata,
201
+ data=data,
202
+ context=context,
203
+ created_at=created_at,
204
+ idempotency_key=idempotency_key,
205
+ default_trail=self._client.trail,
206
+ field_prefix="",
207
+ )
208
+ body = _envelope_model(envelope)
209
+ key = envelope["idempotency_key"]
210
+ outbox = self._client._outbox
211
+ if outbox is not None:
212
+ outbox.enqueue(cast(EventInput, envelope))
213
+ return EmitResult(event=None, duplicate=False, idempotency_key=key, queued=True)
214
+
215
+ def attempt() -> EmitResult:
216
+ status, headers, raw = _attempt(
217
+ lambda: self._client._api.events_create_without_preload_content(
218
+ event_ingest_request=EventIngestRequest(actual_instance=body),
219
+ _request_timeout=self._client._timeout_seconds,
220
+ )
221
+ )
222
+ if status in (200, 201):
223
+ return EmitResult(
224
+ event=_decode(AuditEvent, raw, status),
225
+ duplicate=status == 200,
226
+ idempotency_key=key,
227
+ )
228
+ raise _error_from_response(status, headers, raw)
229
+
230
+ return self._client._retry.run(attempt)
231
+
232
+ def emit_batch(self, events: Sequence[EventInput]) -> BatchResult:
233
+ """Index up to 500 events in one request, one generated key per item."""
234
+ return self._emit_batch(events)[1]
235
+
236
+ def _emit_batch(self, events: Sequence[EventInput], *, retries: bool = True) -> tuple[int, BatchResult]:
237
+ """:meth:`emit_batch`, plus the response status (202 or 207) the outbox reports; ``retries=False`` sends once."""
238
+ if not events:
239
+ raise ValidationError("events must not be empty", field="events")
240
+ envelopes = [
241
+ _build_envelope(
242
+ event=item.get("event"),
243
+ trail=item.get("trail"),
244
+ organization_id=item.get("organization_id"),
245
+ actor=item.get("actor"),
246
+ targets=item.get("targets"),
247
+ metadata=item.get("metadata"),
248
+ data=item.get("data"),
249
+ context=item.get("context"),
250
+ created_at=item.get("created_at"),
251
+ idempotency_key=item.get("idempotency_key"),
252
+ default_trail=self._client.trail,
253
+ field_prefix=f"events[{index}].",
254
+ )
255
+ for index, item in enumerate(events)
256
+ ]
257
+ try:
258
+ batch = EventBatchRequest.model_validate({"events": envelopes})
259
+ except PydanticValidationError as err:
260
+ raise _validation_error(err) from err
261
+
262
+ def attempt() -> tuple[int, BatchResult]:
263
+ status, headers, raw = _attempt(
264
+ lambda: self._client._api.events_create_without_preload_content(
265
+ event_ingest_request=EventIngestRequest(actual_instance=batch),
266
+ _request_timeout=self._client._timeout_seconds,
267
+ )
268
+ )
269
+ if status in (202, 207):
270
+ result = _decode(EventBatchResult, raw, status)
271
+ return status, BatchResult(
272
+ accepted=result.accepted,
273
+ rejected=result.rejected,
274
+ results=[_batch_item(item) for item in result.results],
275
+ )
276
+ raise _error_from_response(status, headers, raw)
277
+
278
+ return self._client._retry.run(attempt) if retries else attempt()
279
+
280
+ def list(
281
+ self,
282
+ *,
283
+ trail: str | None = None,
284
+ event: str | None = None,
285
+ event_prefix: str | None = None,
286
+ organization_id: str | None = None,
287
+ actor_id: str | None = None,
288
+ actor_type: str | None = None,
289
+ target_type: str | None = None,
290
+ target_id: str | None = None,
291
+ created_after: str | None = None,
292
+ created_before: str | None = None,
293
+ q: str | None = None,
294
+ sort: str | None = None,
295
+ limit: int | None = None,
296
+ cursor: str | None = None,
297
+ ) -> EventPage:
298
+ """Read one page of events; the client's default trail does not apply."""
299
+ params: dict[str, Any] = {}
300
+ for name, value in (
301
+ ("actor_id", actor_id),
302
+ ("actor_type", actor_type),
303
+ ("created_after", created_after),
304
+ ("created_before", created_before),
305
+ ("cursor", cursor),
306
+ ("event", event),
307
+ ("event_prefix", event_prefix),
308
+ ("limit", limit),
309
+ ("organization_id", organization_id),
310
+ ("q", q),
311
+ ("sort", sort),
312
+ ("target_id", target_id),
313
+ ("target_type", target_type),
314
+ ("trail", trail),
315
+ ):
316
+ if value is not None:
317
+ params[name] = value
318
+
319
+ def attempt() -> EventPage:
320
+ try:
321
+ response = self._client._api.events_list_without_preload_content(
322
+ **params, _request_timeout=self._client._timeout_seconds
323
+ )
324
+ except PydanticValidationError as err:
325
+ raise _validation_error(err) from err
326
+ status, headers, raw = _read(response)
327
+ if status != 200:
328
+ raise _error_from_response(status, headers, raw)
329
+ page = _decode(PaginatedAuditEventList, raw, status)
330
+ return EventPage(results=page.results, next_cursor=_next_cursor(page.next))
331
+
332
+ return self._client._retry.run(attempt)
333
+
334
+ def iterate(
335
+ self,
336
+ *,
337
+ trail: str | None = None,
338
+ event: str | None = None,
339
+ event_prefix: str | None = None,
340
+ organization_id: str | None = None,
341
+ actor_id: str | None = None,
342
+ actor_type: str | None = None,
343
+ target_type: str | None = None,
344
+ target_id: str | None = None,
345
+ created_after: str | None = None,
346
+ created_before: str | None = None,
347
+ q: str | None = None,
348
+ sort: str | None = None,
349
+ limit: int | None = None,
350
+ ) -> Iterator[AuditEvent]:
351
+ """Lazily yield every matching event, page by page."""
352
+ cursor: str | None = None
353
+ while True:
354
+ page = self.list(
355
+ trail=trail,
356
+ event=event,
357
+ event_prefix=event_prefix,
358
+ organization_id=organization_id,
359
+ actor_id=actor_id,
360
+ actor_type=actor_type,
361
+ target_type=target_type,
362
+ target_id=target_id,
363
+ created_after=created_after,
364
+ created_before=created_before,
365
+ q=q,
366
+ sort=sort,
367
+ limit=limit,
368
+ cursor=cursor,
369
+ )
370
+ yield from page.results
371
+ if page.next_cursor is None:
372
+ return
373
+ cursor = page.next_cursor
374
+
375
+
376
+ def _apply_default_headers(client: ApiClient, headers: Mapping[str, str]) -> None:
377
+ """Set headers every request carries; defaults beat the generated per-call ones."""
378
+ for name, value in headers.items():
379
+ client.set_default_header(name, value) # type: ignore[no-untyped-call] # generated
380
+
381
+
382
+ def _build_envelope(
383
+ *,
384
+ event: str | None,
385
+ trail: str | None,
386
+ organization_id: str | None,
387
+ actor: ActorInput | None,
388
+ targets: Sequence[TargetInput] | None,
389
+ metadata: Mapping[str, str] | None,
390
+ data: Any,
391
+ context: Mapping[str, Any] | None,
392
+ created_at: str | None,
393
+ idempotency_key: str | None,
394
+ default_trail: str | None,
395
+ field_prefix: str,
396
+ ) -> dict[str, Any]:
397
+ """The wire envelope: resolved trail, a key, and only the supplied members."""
398
+ if not event:
399
+ raise ValidationError("event must be a non-empty string", field=f"{field_prefix}event")
400
+ resolved_trail = trail or default_trail
401
+ if not resolved_trail:
402
+ raise ValidationError(
403
+ "trail must be resolved: supply one on the event or set a client default",
404
+ field=f"{field_prefix}trail",
405
+ )
406
+ envelope: dict[str, Any] = {
407
+ "event": event,
408
+ "trail": resolved_trail,
409
+ "idempotency_key": idempotency_key or str(uuid.uuid4()),
410
+ }
411
+ for name, value in (
412
+ ("organization_id", organization_id),
413
+ ("actor", actor),
414
+ ("targets", list(targets) if targets is not None else None),
415
+ ("metadata", metadata),
416
+ ("data", data),
417
+ ("context", context),
418
+ ("created_at", created_at),
419
+ ):
420
+ if value is not None:
421
+ envelope[name] = value
422
+ return envelope
423
+
424
+
425
+ def _envelope_model(envelope: Mapping[str, Any]) -> EventEnvelopeRequest:
426
+ """Validate the envelope with the generated request model."""
427
+ try:
428
+ return EventEnvelopeRequest.model_validate(dict(envelope))
429
+ except PydanticValidationError as err:
430
+ raise _validation_error(err) from err
431
+
432
+
433
+ def _validation_error(err: PydanticValidationError) -> ValidationError:
434
+ location = err.errors()[0]["loc"]
435
+ return ValidationError(str(err), field=".".join(str(part) for part in location))
436
+
437
+
438
+ def _attempt(request: Callable[[], urllib3.HTTPResponse]) -> tuple[int, Any, bytes]:
439
+ """Make one request and read its body, mapping transport failures."""
440
+ try:
441
+ response = request()
442
+ try:
443
+ return response.status, response.headers, response.data
444
+ finally:
445
+ response.release_conn()
446
+ except urllib3.exceptions.HTTPError as err:
447
+ raise TransportError(f"{type(err).__name__}: {err}") from err
448
+
449
+
450
+ def _read(response: urllib3.HTTPResponse) -> tuple[int, Any, bytes]:
451
+ """Read a response that was already made (see :func:`_attempt`)."""
452
+ return _attempt(lambda: response)
453
+
454
+
455
+ def _decode(model: type[_MODEL], body: bytes, status: int) -> _MODEL:
456
+ """Parse a success body with the generated read model."""
457
+ try:
458
+ parsed = json.loads(body)
459
+ except ValueError as err:
460
+ raise ApiError(f"HTTP {status}: response body is not JSON", status=status) from err
461
+ try:
462
+ return model.model_validate(parsed)
463
+ except PydanticValidationError as err:
464
+ raise ApiError(f"HTTP {status}: response body does not match the schema", status=status) from err
465
+
466
+
467
+ def _batch_item(item: EventBatchItemResult) -> BatchItem:
468
+ error = None
469
+ if item.error is not None:
470
+ error = BatchItemError(code=item.error.code, message=item.error.message, field=item.error.var_field)
471
+ return BatchItem(index=item.index, status=item.status, id=item.id, error=error)
472
+
473
+
474
+ def _next_cursor(next_url: str | None) -> str | None:
475
+ """The decoded ``cursor`` of a page's ``next`` URL, or ``None``."""
476
+ if not next_url:
477
+ return None
478
+ values = urllib.parse.parse_qs(urllib.parse.urlsplit(next_url).query).get("cursor")
479
+ return values[0] if values else None
480
+
481
+
482
+ def _error_from_response(status: int, headers: Any, body: bytes) -> SantatiError:
483
+ kind = _error_kind(status)
484
+ code: str | None = None
485
+ field: str | None = None
486
+ message = f"HTTP {status}"
487
+ try:
488
+ parsed = json.loads(body)
489
+ except ValueError:
490
+ parsed = None
491
+ if isinstance(parsed, dict):
492
+ error = parsed.get("error")
493
+ if isinstance(error, dict) and isinstance(error.get("code"), str):
494
+ code = error["code"]
495
+ if isinstance(error.get("field"), str):
496
+ field = error["field"]
497
+ if isinstance(error.get("message"), str):
498
+ message = error["message"]
499
+ elif isinstance(parsed.get("detail"), str):
500
+ message = parsed["detail"]
501
+ return kind(message, status=status, code=code, field=field, retry_after=_retry_after(headers))
502
+
503
+
504
+ def _error_kind(status: int) -> type[SantatiError]:
505
+ if status in (400, 413, 422):
506
+ return ValidationError
507
+ if status in (401, 403):
508
+ return AuthError
509
+ if status == 404:
510
+ return NotFoundError
511
+ if status == 429:
512
+ return RateLimitedError
513
+ if 500 <= status <= 599:
514
+ return ServerError
515
+ return ApiError
516
+
517
+
518
+ def _retry_after(headers: Any) -> int | None:
519
+ value = headers.get("Retry-After")
520
+ if isinstance(value, str) and re.fullmatch(r"\d+", value):
521
+ return int(value)
522
+ return None
santati/_errors.py ADDED
@@ -0,0 +1,68 @@
1
+ """Errors raised by the Santati SDK.
2
+
3
+ Every failure is a :class:`SantatiError`; the eight subclasses are the kinds
4
+ described in ``docs/sdk-surface.md``. ``status`` is ``None`` for local
5
+ validation failures, transport errors and outbox errors.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class SantatiError(Exception):
12
+ """Base class for every error this SDK raises."""
13
+
14
+ def __init__(
15
+ self,
16
+ message: str,
17
+ *,
18
+ status: int | None = None,
19
+ code: str | None = None,
20
+ field: str | None = None,
21
+ retry_after: int | None = None,
22
+ ) -> None:
23
+ super().__init__(message)
24
+ self.message = message
25
+ """Human-readable explanation of the failure."""
26
+ self.status = status
27
+ """HTTP status of the failing response, or ``None`` when there was none."""
28
+ self.code = code
29
+ """The server's machine-readable error code, when it sent one."""
30
+ self.field = field
31
+ """The request field at fault, when the server named one."""
32
+ self.retry_after = retry_after
33
+ """Seconds from the response's ``Retry-After`` header, or ``None``."""
34
+
35
+
36
+ class ValidationError(SantatiError):
37
+ """The request was rejected locally or by the server (400, 413, 422)."""
38
+
39
+
40
+ class AuthError(SantatiError):
41
+ """The API key is missing, invalid or not scoped to the request (401, 403)."""
42
+
43
+
44
+ class NotFoundError(SantatiError):
45
+ """The addressed resource does not exist (404)."""
46
+
47
+
48
+ class RateLimitedError(SantatiError):
49
+ """The team is over its rate limit (429)."""
50
+
51
+
52
+ class ServerError(SantatiError):
53
+ """Santati failed to serve the request (500-599)."""
54
+
55
+
56
+ class TransportError(SantatiError):
57
+ """No HTTP response arrived: refused, DNS, TLS or timeout."""
58
+
59
+
60
+ class ApiError(SantatiError):
61
+ """Any other failure: an unexpected status or an undecodable success body."""
62
+
63
+
64
+ class OutboxError(SantatiError):
65
+ """The outbox store refused or failed, or a ``pre_send`` hook raised (status ``None``).
66
+
67
+ ``code`` is ``outbox_full``, ``store_unavailable``, ``closed`` or ``hook_failed``.
68
+ """