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.
- sentryrelay/__init__.py +87 -0
- sentryrelay/client.py +479 -0
- sentryrelay/constants.py +28 -0
- sentryrelay/errors.py +175 -0
- sentryrelay/integrations/__init__.py +65 -0
- sentryrelay/integrations/agno.py +140 -0
- sentryrelay/integrations/browser_use.py +122 -0
- sentryrelay/integrations/claude_agent.py +215 -0
- sentryrelay/integrations/crewai.py +75 -0
- sentryrelay/integrations/google_adk.py +198 -0
- sentryrelay/integrations/langgraph.py +115 -0
- sentryrelay/integrations/microsoft_agent.py +161 -0
- sentryrelay/integrations/openai_agents.py +158 -0
- sentryrelay/integrations/playwright.py +43 -0
- sentryrelay/integrations/pydantic_ai.py +188 -0
- sentryrelay/integrations/smolagents.py +182 -0
- sentryrelay/integrations/strands.py +175 -0
- sentryrelay/models.py +202 -0
- sentryrelay/py.typed +0 -0
- sentryrelay/resources/__init__.py +19 -0
- sentryrelay/resources/audit_logs.py +81 -0
- sentryrelay/resources/identities.py +366 -0
- sentryrelay/resources/totp.py +205 -0
- sentryrelay/resources/webhooks.py +81 -0
- sentryrelay_sdk-0.1.0.dist-info/METADATA +464 -0
- sentryrelay_sdk-0.1.0.dist-info/RECORD +27 -0
- sentryrelay_sdk-0.1.0.dist-info/WHEEL +4 -0
sentryrelay/__init__.py
ADDED
|
@@ -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
|
sentryrelay/constants.py
ADDED
|
@@ -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
|