sentryrelay-sdk 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.
@@ -0,0 +1,87 @@
1
+ """
2
+ Sentry Relay Official Python SDK
3
+ The 2FA, OTP & Magic-Link Inbox API for Autonomous Software.
4
+ """
5
+
6
+ from sentryrelay.client import (
7
+ AsyncSentryRelay,
8
+ AsyncSentryRelayClient,
9
+ SentryRelay,
10
+ SentryRelayClient,
11
+ )
12
+ from sentryrelay.errors import (
13
+ AuthenticationError,
14
+ BadGatewayError,
15
+ ConflictError,
16
+ InternalServerError,
17
+ NotFoundError,
18
+ PaymentRequiredError,
19
+ PermissionDeniedError,
20
+ QuotaExceededError,
21
+ RateLimitError,
22
+ SentryRelayApiError,
23
+ SentryRelayError,
24
+ ValidationError,
25
+ )
26
+ from sentryrelay.integrations.playwright import fill_otp_code
27
+ from sentryrelay.models import (
28
+ AuditLog,
29
+ CustomExtractionRule,
30
+ DeleteIdentityResponse,
31
+ DeleteTotpSecretResponse,
32
+ DeleteWebhookResponse,
33
+ ExtractedAuthEvent,
34
+ GeneratedTotpCode,
35
+ HumanFallbackConfig,
36
+ Identity,
37
+ IdentityListResponse,
38
+ LatestResponse,
39
+ OutboundWebhook,
40
+ ProvisionSmsDidResponse,
41
+ ReleaseSmsDidResponse,
42
+ TotpSecretMetadata,
43
+ WaitResponse,
44
+ )
45
+
46
+ __version__ = "0.1.0"
47
+
48
+ __all__ = [
49
+ # Clients
50
+ "SentryRelay",
51
+ "AsyncSentryRelay",
52
+ "SentryRelayClient",
53
+ "AsyncSentryRelayClient",
54
+ # Errors
55
+ "SentryRelayError",
56
+ "SentryRelayApiError",
57
+ "AuthenticationError",
58
+ "PaymentRequiredError",
59
+ "PermissionDeniedError",
60
+ "NotFoundError",
61
+ "ConflictError",
62
+ "ValidationError",
63
+ "RateLimitError",
64
+ "QuotaExceededError",
65
+ "InternalServerError",
66
+ "BadGatewayError",
67
+ # Models
68
+ "Identity",
69
+ "IdentityListResponse",
70
+ "DeleteIdentityResponse",
71
+ "ProvisionSmsDidResponse",
72
+ "ReleaseSmsDidResponse",
73
+ "WaitResponse",
74
+ "LatestResponse",
75
+ "ExtractedAuthEvent",
76
+ "GeneratedTotpCode",
77
+ "TotpSecretMetadata",
78
+ "DeleteTotpSecretResponse",
79
+ "OutboundWebhook",
80
+ "DeleteWebhookResponse",
81
+ "AuditLog",
82
+ "HumanFallbackConfig",
83
+ "CustomExtractionRule",
84
+ # Helpers
85
+ "fill_otp_code",
86
+ "__version__",
87
+ ]
sentryrelay/client.py ADDED
@@ -0,0 +1,479 @@
1
+ """
2
+ Official Sentry Relay Python SDK Client (Sync & Async)
3
+ """
4
+
5
+ import asyncio
6
+ import os
7
+ import random
8
+ import time
9
+ from typing import Any, Dict, Literal, Optional, Union
10
+
11
+ import httpx
12
+
13
+ from sentryrelay.constants import (
14
+ API_KEY_REGEX,
15
+ DEFAULT_BASE_URL,
16
+ DEFAULT_MAX_RETRIES,
17
+ DEFAULT_RETRY_DELAY_SECONDS,
18
+ DEFAULT_TIMEOUT_SECONDS,
19
+ )
20
+ from sentryrelay.errors import SentryRelayApiError, raise_for_api_error
21
+ from sentryrelay.models import LatestResponse, WaitResponse
22
+ from sentryrelay.resources.audit_logs import AsyncAuditLogsResource, AuditLogsResource
23
+ from sentryrelay.resources.identities import AsyncIdentitiesResource, IdentitiesResource
24
+ from sentryrelay.resources.totp import AsyncTotpResource, TotpResource
25
+ from sentryrelay.resources.webhooks import AsyncWebhooksResource, WebhooksResource
26
+
27
+ SDK_VERSION = "0.1.0"
28
+
29
+
30
+ def _validate_api_key(api_key: Optional[str]) -> str:
31
+ key = api_key or os.environ.get("SENTRY_RELAY_API_KEY")
32
+ if not key or not API_KEY_REGEX.match(key):
33
+ raise SentryRelayApiError(
34
+ code="INVALID_API_KEY",
35
+ message="API key must match sr_live_<32_base62> or sr_test_<32_base62>",
36
+ status_code=401,
37
+ )
38
+ return key
39
+
40
+
41
+ def _normalize_base_url(base_url: Optional[str]) -> str:
42
+ url = base_url or os.environ.get("SENTRY_RELAY_BASE_URL") or DEFAULT_BASE_URL
43
+ return url.rstrip("/")
44
+
45
+
46
+ class SentryRelay:
47
+ """
48
+ Official Sentry Relay Synchronous Client.
49
+ Zero-retention 2FA, OTP & Magic-Link Vault client for autonomous software.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ api_key: Optional[str] = None,
55
+ base_url: Optional[str] = None,
56
+ max_retries: int = DEFAULT_MAX_RETRIES,
57
+ retry_delay_seconds: float = DEFAULT_RETRY_DELAY_SECONDS,
58
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
59
+ http_client: Optional[httpx.Client] = None,
60
+ ) -> None:
61
+ self.api_key: str = _validate_api_key(api_key)
62
+ self.base_url: str = _normalize_base_url(base_url)
63
+ self.max_retries: int = max_retries
64
+ self.retry_delay_seconds: float = retry_delay_seconds
65
+ self.timeout: float = timeout
66
+
67
+ self._owns_http_client: bool = http_client is None
68
+ self._http_client: httpx.Client = http_client or httpx.Client(
69
+ timeout=httpx.Timeout(self.timeout),
70
+ )
71
+
72
+ self.identities: IdentitiesResource = IdentitiesResource(self)
73
+ self.totp: TotpResource = TotpResource(self)
74
+ self.webhooks: WebhooksResource = WebhooksResource(self)
75
+ self.audit_logs: AuditLogsResource = AuditLogsResource(self)
76
+
77
+ def get_auth_headers(self) -> Dict[str, str]:
78
+ """Headers required for all authenticated API requests."""
79
+ return {
80
+ "Authorization": f"Bearer {self.api_key}",
81
+ "Content-Type": "application/json",
82
+ "Accept": "application/json",
83
+ "User-Agent": f"sentryrelay-python/{SDK_VERSION}",
84
+ }
85
+
86
+ def sleep(self, seconds: float) -> None:
87
+ """Internal sleep helper, overridable in tests."""
88
+ time.sleep(seconds)
89
+
90
+ def request(
91
+ self,
92
+ method: str,
93
+ path: str,
94
+ query: Optional[Dict[str, Any]] = None,
95
+ body: Optional[Any] = None,
96
+ headers: Optional[Dict[str, str]] = None,
97
+ skip_retry: bool = False,
98
+ ) -> Any:
99
+ """
100
+ Execute an HTTP request with automatic exponential backoff for transient errors.
101
+ """
102
+ url = f"{self.base_url}{path}"
103
+ req_headers = {**self.get_auth_headers(), **(headers or {})}
104
+
105
+ params: Dict[str, str] = {}
106
+ if query:
107
+ for k, v in query.items():
108
+ if v is not None:
109
+ params[k] = str(v)
110
+
111
+ method_upper = method.upper()
112
+ is_mutating = method_upper != "GET"
113
+ effective_max_retries = 0 if skip_retry else self.max_retries
114
+
115
+ attempt = 0
116
+ while True:
117
+ try:
118
+ response = self._http_client.request(
119
+ method=method_upper,
120
+ url=url,
121
+ params=params or None,
122
+ json=body if body is not None else None,
123
+ headers=req_headers,
124
+ )
125
+
126
+ content_type = response.headers.get("content-type", "")
127
+ is_json = "application/json" in content_type
128
+
129
+ if response.is_success:
130
+ if is_json:
131
+ payload = response.json()
132
+ if isinstance(payload, dict) and "data" in payload:
133
+ return payload["data"]
134
+ return payload
135
+ return response.text
136
+
137
+ # Parse error response
138
+ error_code = "INTERNAL_ERROR"
139
+ error_message = f"HTTP {response.status_code} {response.reason_phrase}"
140
+ error_details: Optional[Dict[str, Any]] = None
141
+ request_id: Optional[str] = response.headers.get("x-request-id")
142
+
143
+ if is_json:
144
+ try:
145
+ err_payload = response.json()
146
+ if isinstance(err_payload, dict) and "error" in err_payload:
147
+ err_info = err_payload["error"]
148
+ if isinstance(err_info, dict):
149
+ error_code = err_info.get("code", error_code)
150
+ error_message = err_info.get("message", error_message)
151
+ error_details = err_info.get("details")
152
+ request_id = err_info.get("requestId", request_id)
153
+ except Exception:
154
+ pass
155
+
156
+ is_transient_status = response.status_code >= 500 or response.status_code == 429
157
+ can_retry = not is_mutating and is_transient_status and attempt < effective_max_retries
158
+
159
+ if can_retry:
160
+ attempt += 1
161
+ delay = min(
162
+ 1.0,
163
+ self.retry_delay_seconds * (2 ** attempt) + random.uniform(0, 0.02),
164
+ )
165
+ self.sleep(delay)
166
+ continue
167
+
168
+ raise_for_api_error(
169
+ code=error_code,
170
+ message=error_message,
171
+ status_code=response.status_code,
172
+ details=error_details,
173
+ request_id=request_id,
174
+ )
175
+
176
+ except SentryRelayApiError:
177
+ raise
178
+ except (httpx.NetworkError, httpx.TimeoutException) as exc:
179
+ can_retry = not is_mutating and attempt < effective_max_retries
180
+ if can_retry:
181
+ attempt += 1
182
+ delay = min(
183
+ 1.0,
184
+ self.retry_delay_seconds * (2 ** attempt) + random.uniform(0, 0.02),
185
+ )
186
+ self.sleep(delay)
187
+ continue
188
+
189
+ raise SentryRelayApiError(
190
+ code="INTERNAL_ERROR",
191
+ message=f"Network error during Sentry Relay request: {exc}",
192
+ status_code=500,
193
+ ) from exc
194
+ except Exception as exc:
195
+ raise SentryRelayApiError(
196
+ code="INTERNAL_ERROR",
197
+ message=f"Unexpected error during Sentry Relay request: {exc}",
198
+ status_code=500,
199
+ ) from exc
200
+
201
+ # Top-level ergonomic aliases
202
+ def wait(
203
+ self,
204
+ id_or_slug: str,
205
+ timeout: Optional[int] = 30,
206
+ since: Optional[int] = None,
207
+ channel: Optional[Literal["email", "sms"]] = None,
208
+ sender: Optional[str] = None,
209
+ subject: Optional[str] = None,
210
+ consume: Optional[bool] = True,
211
+ ) -> WaitResponse:
212
+ """
213
+ Long-polling wait primitive for Email OTPs, Magic Links, and SMS verification codes.
214
+ Blocks up to `timeout` seconds until a matching auth event arrives.
215
+ """
216
+ return self.identities.wait(
217
+ id_or_slug=id_or_slug,
218
+ timeout=timeout,
219
+ since=since,
220
+ channel=channel,
221
+ sender=sender,
222
+ subject=subject,
223
+ consume=consume,
224
+ )
225
+
226
+ def latest(
227
+ self,
228
+ id_or_slug: str,
229
+ lookback_seconds: Optional[int] = 60,
230
+ since: Optional[int] = None,
231
+ channel: Optional[Literal["email", "sms"]] = None,
232
+ sender: Optional[str] = None,
233
+ subject: Optional[str] = None,
234
+ consume: Optional[bool] = True,
235
+ ) -> LatestResponse:
236
+ """Instant non-blocking check against the Redis timeline for received codes."""
237
+ return self.identities.latest(
238
+ id_or_slug=id_or_slug,
239
+ lookback_seconds=lookback_seconds,
240
+ since=since,
241
+ channel=channel,
242
+ sender=sender,
243
+ subject=subject,
244
+ consume=consume,
245
+ )
246
+
247
+ def close(self) -> None:
248
+ """Close the underlying HTTP client session if owned by this instance."""
249
+ if self._owns_http_client:
250
+ self._http_client.close()
251
+
252
+ def __enter__(self) -> "SentryRelay":
253
+ return self
254
+
255
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
256
+ self.close()
257
+
258
+
259
+ class AsyncSentryRelay:
260
+ """
261
+ Official Sentry Relay Asynchronous Client.
262
+ Zero-retention 2FA, OTP & Magic-Link Vault client for async autonomous software
263
+ (Browser Use, Playwright Python async, LangGraph, CrewAI).
264
+ """
265
+
266
+ def __init__(
267
+ self,
268
+ api_key: Optional[str] = None,
269
+ base_url: Optional[str] = None,
270
+ max_retries: int = DEFAULT_MAX_RETRIES,
271
+ retry_delay_seconds: float = DEFAULT_RETRY_DELAY_SECONDS,
272
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
273
+ http_client: Optional[httpx.AsyncClient] = None,
274
+ ) -> None:
275
+ self.api_key: str = _validate_api_key(api_key)
276
+ self.base_url: str = _normalize_base_url(base_url)
277
+ self.max_retries: int = max_retries
278
+ self.retry_delay_seconds: float = retry_delay_seconds
279
+ self.timeout: float = timeout
280
+
281
+ self._owns_http_client: bool = http_client is None
282
+ self._http_client: httpx.AsyncClient = http_client or httpx.AsyncClient(
283
+ timeout=httpx.Timeout(self.timeout),
284
+ )
285
+
286
+ self.identities: AsyncIdentitiesResource = AsyncIdentitiesResource(self)
287
+ self.totp: AsyncTotpResource = AsyncTotpResource(self)
288
+ self.webhooks: AsyncWebhooksResource = AsyncWebhooksResource(self)
289
+ self.audit_logs: AsyncAuditLogsResource = AsyncAuditLogsResource(self)
290
+
291
+ def get_auth_headers(self) -> Dict[str, str]:
292
+ """Headers required for all authenticated API requests."""
293
+ return {
294
+ "Authorization": f"Bearer {self.api_key}",
295
+ "Content-Type": "application/json",
296
+ "Accept": "application/json",
297
+ "User-Agent": f"sentryrelay-python/{SDK_VERSION}",
298
+ }
299
+
300
+ async def sleep(self, seconds: float) -> None:
301
+ """Internal async sleep helper, overridable in tests."""
302
+ await asyncio.sleep(seconds)
303
+
304
+ async def request(
305
+ self,
306
+ method: str,
307
+ path: str,
308
+ query: Optional[Dict[str, Any]] = None,
309
+ body: Optional[Any] = None,
310
+ headers: Optional[Dict[str, str]] = None,
311
+ skip_retry: bool = False,
312
+ ) -> Any:
313
+ """
314
+ Execute an asynchronous HTTP request with automatic exponential backoff.
315
+ """
316
+ url = f"{self.base_url}{path}"
317
+ req_headers = {**self.get_auth_headers(), **(headers or {})}
318
+
319
+ params: Dict[str, str] = {}
320
+ if query:
321
+ for k, v in query.items():
322
+ if v is not None:
323
+ params[k] = str(v)
324
+
325
+ method_upper = method.upper()
326
+ is_mutating = method_upper != "GET"
327
+ effective_max_retries = 0 if skip_retry else self.max_retries
328
+
329
+ attempt = 0
330
+ while True:
331
+ try:
332
+ response = await self._http_client.request(
333
+ method=method_upper,
334
+ url=url,
335
+ params=params or None,
336
+ json=body if body is not None else None,
337
+ headers=req_headers,
338
+ )
339
+
340
+ content_type = response.headers.get("content-type", "")
341
+ is_json = "application/json" in content_type
342
+
343
+ if response.is_success:
344
+ if is_json:
345
+ payload = response.json()
346
+ if isinstance(payload, dict) and "data" in payload:
347
+ return payload["data"]
348
+ return payload
349
+ return response.text
350
+
351
+ # Parse error response
352
+ error_code = "INTERNAL_ERROR"
353
+ error_message = f"HTTP {response.status_code} {response.reason_phrase}"
354
+ error_details: Optional[Dict[str, Any]] = None
355
+ request_id: Optional[str] = response.headers.get("x-request-id")
356
+
357
+ if is_json:
358
+ try:
359
+ err_payload = response.json()
360
+ if isinstance(err_payload, dict) and "error" in err_payload:
361
+ err_info = err_payload["error"]
362
+ if isinstance(err_info, dict):
363
+ error_code = err_info.get("code", error_code)
364
+ error_message = err_info.get("message", error_message)
365
+ error_details = err_info.get("details")
366
+ request_id = err_info.get("requestId", request_id)
367
+ except Exception:
368
+ pass
369
+
370
+ is_transient_status = response.status_code >= 500 or response.status_code == 429
371
+ can_retry = not is_mutating and is_transient_status and attempt < effective_max_retries
372
+
373
+ if can_retry:
374
+ attempt += 1
375
+ delay = min(
376
+ 1.0,
377
+ self.retry_delay_seconds * (2 ** attempt) + random.uniform(0, 0.02),
378
+ )
379
+ await self.sleep(delay)
380
+ continue
381
+
382
+ raise_for_api_error(
383
+ code=error_code,
384
+ message=error_message,
385
+ status_code=response.status_code,
386
+ details=error_details,
387
+ request_id=request_id,
388
+ )
389
+
390
+ except SentryRelayApiError:
391
+ raise
392
+ except (httpx.NetworkError, httpx.TimeoutException) as exc:
393
+ can_retry = not is_mutating and attempt < effective_max_retries
394
+ if can_retry:
395
+ attempt += 1
396
+ delay = min(
397
+ 1.0,
398
+ self.retry_delay_seconds * (2 ** attempt) + random.uniform(0, 0.02),
399
+ )
400
+ await self.sleep(delay)
401
+ continue
402
+
403
+ raise SentryRelayApiError(
404
+ code="INTERNAL_ERROR",
405
+ message=f"Network error during Sentry Relay request: {exc}",
406
+ status_code=500,
407
+ ) from exc
408
+ except Exception as exc:
409
+ raise SentryRelayApiError(
410
+ code="INTERNAL_ERROR",
411
+ message=f"Unexpected error during Sentry Relay request: {exc}",
412
+ status_code=500,
413
+ ) from exc
414
+
415
+ # Top-level ergonomic aliases
416
+ async def wait(
417
+ self,
418
+ id_or_slug: str,
419
+ timeout: Optional[int] = 30,
420
+ since: Optional[int] = None,
421
+ channel: Optional[Literal["email", "sms"]] = None,
422
+ sender: Optional[str] = None,
423
+ subject: Optional[str] = None,
424
+ consume: Optional[bool] = True,
425
+ ) -> WaitResponse:
426
+ """
427
+ Long-polling wait primitive for Email OTPs, Magic Links, and SMS verification codes.
428
+ Blocks up to `timeout` seconds until a matching auth event arrives.
429
+ """
430
+ return await self.identities.wait(
431
+ id_or_slug=id_or_slug,
432
+ timeout=timeout,
433
+ since=since,
434
+ channel=channel,
435
+ sender=sender,
436
+ subject=subject,
437
+ consume=consume,
438
+ )
439
+
440
+ async def latest(
441
+ self,
442
+ id_or_slug: str,
443
+ lookback_seconds: Optional[int] = 60,
444
+ since: Optional[int] = None,
445
+ channel: Optional[Literal["email", "sms"]] = None,
446
+ sender: Optional[str] = None,
447
+ subject: Optional[str] = None,
448
+ consume: Optional[bool] = True,
449
+ ) -> LatestResponse:
450
+ """Instant non-blocking check against the Redis timeline for received codes."""
451
+ return await self.identities.latest(
452
+ id_or_slug=id_or_slug,
453
+ lookback_seconds=lookback_seconds,
454
+ since=since,
455
+ channel=channel,
456
+ sender=sender,
457
+ subject=subject,
458
+ consume=consume,
459
+ )
460
+
461
+ async def aclose(self) -> None:
462
+ """Close the underlying HTTP client session if owned by this instance."""
463
+ if self._owns_http_client:
464
+ await self._http_client.aclose()
465
+
466
+ async def close(self) -> None:
467
+ """Alias for aclose()."""
468
+ await self.aclose()
469
+
470
+ async def __aenter__(self) -> "AsyncSentryRelay":
471
+ return self
472
+
473
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
474
+ await self.aclose()
475
+
476
+
477
+ # Backward-compatible and conversational aliases
478
+ SentryRelayClient = SentryRelay
479
+ AsyncSentryRelayClient = AsyncSentryRelay
@@ -0,0 +1,28 @@
1
+ """
2
+ Sentry Relay Python SDK Constants
3
+ """
4
+
5
+ import re
6
+ from typing import Final
7
+
8
+ PRODUCT_NAME: Final[str] = "Sentry Relay"
9
+ PRIMARY_DOMAIN: Final[str] = "sentryrelay.com"
10
+ DEFAULT_BASE_URL: Final[str] = f"https://{PRIMARY_DOMAIN}"
11
+ DEFAULT_INBOUND_EMAIL_DOMAIN: Final[str] = "in.sentryrelay.com"
12
+
13
+ API_KEY_PREFIX_LIVE: Final[str] = "sr_live_"
14
+ API_KEY_PREFIX_TEST: Final[str] = "sr_test_"
15
+ API_KEY_RANDOM_LENGTH: Final[int] = 32
16
+ API_KEY_REGEX: Final[re.Pattern] = re.compile(r"^sr_(live|test)_[0-9A-Za-z]{32}$")
17
+
18
+ DEFAULT_MAX_RETRIES: Final[int] = 2
19
+ DEFAULT_RETRY_DELAY_SECONDS: Final[float] = 0.15
20
+ DEFAULT_TIMEOUT_SECONDS: Final[float] = 60.0
21
+
22
+ MIN_WAIT_TIMEOUT_SECONDS: Final[int] = 1
23
+ DEFAULT_WAIT_TIMEOUT_SECONDS: Final[int] = 30
24
+ MAX_WAIT_TIMEOUT_SECONDS: Final[int] = 45
25
+ DEFAULT_WAIT_LOOKBACK_MS: Final[int] = 15_000
26
+
27
+ TOTP_EXPIRING_WINDOW_SECONDS: Final[int] = 5
28
+ TOTP_AUTO_WAIT_THRESHOLD_SECONDS: Final[int] = 4