affinity-sdk 0.9.5__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.
- affinity/__init__.py +139 -0
- affinity/cli/__init__.py +7 -0
- affinity/cli/click_compat.py +27 -0
- affinity/cli/commands/__init__.py +1 -0
- affinity/cli/commands/_entity_files_dump.py +219 -0
- affinity/cli/commands/_list_entry_fields.py +41 -0
- affinity/cli/commands/_v1_parsing.py +77 -0
- affinity/cli/commands/company_cmds.py +2139 -0
- affinity/cli/commands/completion_cmd.py +33 -0
- affinity/cli/commands/config_cmds.py +540 -0
- affinity/cli/commands/entry_cmds.py +33 -0
- affinity/cli/commands/field_cmds.py +413 -0
- affinity/cli/commands/interaction_cmds.py +875 -0
- affinity/cli/commands/list_cmds.py +3152 -0
- affinity/cli/commands/note_cmds.py +433 -0
- affinity/cli/commands/opportunity_cmds.py +1174 -0
- affinity/cli/commands/person_cmds.py +1980 -0
- affinity/cli/commands/query_cmd.py +444 -0
- affinity/cli/commands/relationship_strength_cmds.py +62 -0
- affinity/cli/commands/reminder_cmds.py +595 -0
- affinity/cli/commands/resolve_url_cmd.py +127 -0
- affinity/cli/commands/session_cmds.py +84 -0
- affinity/cli/commands/task_cmds.py +110 -0
- affinity/cli/commands/version_cmd.py +29 -0
- affinity/cli/commands/whoami_cmd.py +36 -0
- affinity/cli/config.py +108 -0
- affinity/cli/context.py +749 -0
- affinity/cli/csv_utils.py +195 -0
- affinity/cli/date_utils.py +42 -0
- affinity/cli/decorators.py +77 -0
- affinity/cli/errors.py +28 -0
- affinity/cli/field_utils.py +355 -0
- affinity/cli/formatters.py +551 -0
- affinity/cli/help_json.py +283 -0
- affinity/cli/logging.py +100 -0
- affinity/cli/main.py +261 -0
- affinity/cli/options.py +53 -0
- affinity/cli/paths.py +32 -0
- affinity/cli/progress.py +183 -0
- affinity/cli/query/__init__.py +163 -0
- affinity/cli/query/aggregates.py +357 -0
- affinity/cli/query/dates.py +194 -0
- affinity/cli/query/exceptions.py +147 -0
- affinity/cli/query/executor.py +1236 -0
- affinity/cli/query/filters.py +248 -0
- affinity/cli/query/models.py +333 -0
- affinity/cli/query/output.py +331 -0
- affinity/cli/query/parser.py +619 -0
- affinity/cli/query/planner.py +430 -0
- affinity/cli/query/progress.py +270 -0
- affinity/cli/query/schema.py +439 -0
- affinity/cli/render.py +1589 -0
- affinity/cli/resolve.py +222 -0
- affinity/cli/resolvers.py +249 -0
- affinity/cli/results.py +308 -0
- affinity/cli/runner.py +218 -0
- affinity/cli/serialization.py +65 -0
- affinity/cli/session_cache.py +276 -0
- affinity/cli/types.py +70 -0
- affinity/client.py +771 -0
- affinity/clients/__init__.py +19 -0
- affinity/clients/http.py +3664 -0
- affinity/clients/pipeline.py +165 -0
- affinity/compare.py +501 -0
- affinity/downloads.py +114 -0
- affinity/exceptions.py +615 -0
- affinity/filters.py +1128 -0
- affinity/hooks.py +198 -0
- affinity/inbound_webhooks.py +302 -0
- affinity/models/__init__.py +163 -0
- affinity/models/entities.py +798 -0
- affinity/models/pagination.py +513 -0
- affinity/models/rate_limit_snapshot.py +48 -0
- affinity/models/secondary.py +413 -0
- affinity/models/types.py +663 -0
- affinity/policies.py +40 -0
- affinity/progress.py +22 -0
- affinity/py.typed +0 -0
- affinity/services/__init__.py +42 -0
- affinity/services/companies.py +1286 -0
- affinity/services/lists.py +1892 -0
- affinity/services/opportunities.py +1330 -0
- affinity/services/persons.py +1348 -0
- affinity/services/rate_limits.py +173 -0
- affinity/services/tasks.py +193 -0
- affinity/services/v1_only.py +2445 -0
- affinity/types.py +83 -0
- affinity_sdk-0.9.5.dist-info/METADATA +622 -0
- affinity_sdk-0.9.5.dist-info/RECORD +92 -0
- affinity_sdk-0.9.5.dist-info/WHEEL +4 -0
- affinity_sdk-0.9.5.dist-info/entry_points.txt +2 -0
- affinity_sdk-0.9.5.dist-info/licenses/LICENSE +21 -0
affinity/exceptions.py
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for the Affinity API client.
|
|
3
|
+
|
|
4
|
+
All exceptions inherit from AffinityError for easy catching of all library errors.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class ErrorDiagnostics:
|
|
15
|
+
method: str | None = None
|
|
16
|
+
url: str | None = None
|
|
17
|
+
request_params: dict[str, Any] | None = None
|
|
18
|
+
api_version: str | None = None # "v1" | "v2" (string to avoid circular types)
|
|
19
|
+
base_url: str | None = None
|
|
20
|
+
request_id: str | None = None
|
|
21
|
+
http_version: str | None = None
|
|
22
|
+
response_headers: dict[str, str] | None = None
|
|
23
|
+
response_body_snippet: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AffinityError(Exception):
|
|
27
|
+
"""Base exception for all Affinity API errors."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
message: str,
|
|
32
|
+
*,
|
|
33
|
+
status_code: int | None = None,
|
|
34
|
+
response_body: Any | None = None,
|
|
35
|
+
diagnostics: ErrorDiagnostics | None = None,
|
|
36
|
+
):
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.message = message
|
|
39
|
+
self.status_code = status_code
|
|
40
|
+
self.response_body = response_body
|
|
41
|
+
self.diagnostics = diagnostics
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str:
|
|
44
|
+
base = self.message
|
|
45
|
+
if self.status_code:
|
|
46
|
+
base = f"[{self.status_code}] {base}"
|
|
47
|
+
if self.diagnostics:
|
|
48
|
+
# Include method + url if both present, or just url if only url present
|
|
49
|
+
if self.diagnostics.method and self.diagnostics.url:
|
|
50
|
+
base = f"{base} ({self.diagnostics.method} {self.diagnostics.url})"
|
|
51
|
+
elif self.diagnostics.url:
|
|
52
|
+
base = f"{base} (url={self.diagnostics.url})"
|
|
53
|
+
if self.diagnostics.request_id:
|
|
54
|
+
base = f"{base} [request_id={self.diagnostics.request_id}]"
|
|
55
|
+
return base
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# =============================================================================
|
|
59
|
+
# HTTP Errors
|
|
60
|
+
# =============================================================================
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AuthenticationError(AffinityError):
|
|
64
|
+
"""
|
|
65
|
+
401 Unauthorized - Invalid or missing API key.
|
|
66
|
+
|
|
67
|
+
Your API key is invalid or was not provided.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AuthorizationError(AffinityError):
|
|
74
|
+
"""
|
|
75
|
+
403 Forbidden - Insufficient permissions.
|
|
76
|
+
|
|
77
|
+
You don't have permission to access this resource or perform this action.
|
|
78
|
+
This can happen with:
|
|
79
|
+
- Private lists you don't have access to
|
|
80
|
+
- Admin-only actions
|
|
81
|
+
- Resource-level permission restrictions
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class NotFoundError(AffinityError):
|
|
88
|
+
"""
|
|
89
|
+
404 Not Found - Resource doesn't exist.
|
|
90
|
+
|
|
91
|
+
The requested resource (person, company, list, etc.) was not found.
|
|
92
|
+
This could mean:
|
|
93
|
+
- The ID is invalid
|
|
94
|
+
- The resource was deleted
|
|
95
|
+
- You don't have access to view it
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ValidationError(AffinityError):
|
|
102
|
+
"""
|
|
103
|
+
400/422 Bad Request/Unprocessable Entity - Invalid request data.
|
|
104
|
+
|
|
105
|
+
The request data is malformed or logically invalid.
|
|
106
|
+
Check the error message for details about what's wrong.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
message: str,
|
|
112
|
+
*,
|
|
113
|
+
param: str | None = None,
|
|
114
|
+
status_code: int | None = None,
|
|
115
|
+
response_body: Any | None = None,
|
|
116
|
+
diagnostics: ErrorDiagnostics | None = None,
|
|
117
|
+
):
|
|
118
|
+
super().__init__(
|
|
119
|
+
message,
|
|
120
|
+
status_code=status_code,
|
|
121
|
+
response_body=response_body,
|
|
122
|
+
diagnostics=diagnostics,
|
|
123
|
+
)
|
|
124
|
+
self.param = param
|
|
125
|
+
|
|
126
|
+
def __str__(self) -> str:
|
|
127
|
+
base = super().__str__()
|
|
128
|
+
if self.param:
|
|
129
|
+
return f"{base} (param: {self.param})"
|
|
130
|
+
return base
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class RateLimitError(AffinityError):
|
|
134
|
+
"""
|
|
135
|
+
429 Too Many Requests - Rate limit exceeded.
|
|
136
|
+
|
|
137
|
+
You've exceeded the API rate limit. Wait before retrying.
|
|
138
|
+
Check the response headers for rate limit info:
|
|
139
|
+
- X-Ratelimit-Limit-User-Reset: Seconds until per-minute limit resets
|
|
140
|
+
- X-Ratelimit-Limit-Org-Reset: Seconds until monthly limit resets
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
message: str,
|
|
146
|
+
*,
|
|
147
|
+
retry_after: int | None = None,
|
|
148
|
+
status_code: int | None = None,
|
|
149
|
+
response_body: Any | None = None,
|
|
150
|
+
diagnostics: ErrorDiagnostics | None = None,
|
|
151
|
+
):
|
|
152
|
+
super().__init__(
|
|
153
|
+
message,
|
|
154
|
+
status_code=status_code,
|
|
155
|
+
response_body=response_body,
|
|
156
|
+
diagnostics=diagnostics,
|
|
157
|
+
)
|
|
158
|
+
self.retry_after = retry_after
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class ConflictError(AffinityError):
|
|
162
|
+
"""
|
|
163
|
+
409 Conflict - Resource conflict.
|
|
164
|
+
|
|
165
|
+
The request conflicts with the current state of the resource.
|
|
166
|
+
For example:
|
|
167
|
+
- Trying to create a person with an email that already exists
|
|
168
|
+
- Concurrent modification conflicts
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class ServerError(AffinityError):
|
|
175
|
+
"""
|
|
176
|
+
500/503 Internal Server Error - Server-side problem.
|
|
177
|
+
|
|
178
|
+
Something went wrong on Affinity's servers.
|
|
179
|
+
Try again later, and contact support if the problem persists.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# =============================================================================
|
|
186
|
+
# Client-side Errors
|
|
187
|
+
# =============================================================================
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class ConfigurationError(AffinityError):
|
|
191
|
+
"""
|
|
192
|
+
Configuration error - missing or invalid client configuration.
|
|
193
|
+
|
|
194
|
+
Check that you've provided:
|
|
195
|
+
- A valid API key
|
|
196
|
+
- Correct base URLs (if customizing)
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class TimeoutError(AffinityError):
|
|
203
|
+
"""
|
|
204
|
+
Request timeout.
|
|
205
|
+
|
|
206
|
+
The request took too long to complete.
|
|
207
|
+
This could be due to:
|
|
208
|
+
- Network issues
|
|
209
|
+
- Large data sets
|
|
210
|
+
- Server overload
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
def __init__(
|
|
214
|
+
self,
|
|
215
|
+
message: str,
|
|
216
|
+
*,
|
|
217
|
+
diagnostics: ErrorDiagnostics | None = None,
|
|
218
|
+
):
|
|
219
|
+
super().__init__(message, diagnostics=diagnostics)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class NetworkError(AffinityError):
|
|
223
|
+
"""
|
|
224
|
+
Network-level error.
|
|
225
|
+
|
|
226
|
+
Failed to connect to the Affinity API.
|
|
227
|
+
Check your internet connection and firewall settings.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
def __init__(
|
|
231
|
+
self,
|
|
232
|
+
message: str,
|
|
233
|
+
*,
|
|
234
|
+
diagnostics: ErrorDiagnostics | None = None,
|
|
235
|
+
):
|
|
236
|
+
super().__init__(message, diagnostics=diagnostics)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class PolicyError(AffinityError):
|
|
240
|
+
"""Raised when a client policy blocks an attempted operation."""
|
|
241
|
+
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class WriteNotAllowedError(PolicyError):
|
|
246
|
+
"""Raised when a write operation is attempted while the write policy denies writes."""
|
|
247
|
+
|
|
248
|
+
def __init__(self, message: str, *, method: str, url: str):
|
|
249
|
+
super().__init__(message, diagnostics=ErrorDiagnostics(method=method, url=url))
|
|
250
|
+
self.method = method
|
|
251
|
+
self.url = url
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# =============================================================================
|
|
255
|
+
# Pagination Errors
|
|
256
|
+
# =============================================================================
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class TooManyResultsError(AffinityError):
|
|
260
|
+
"""
|
|
261
|
+
Raised when ``.all()`` exceeds the limit.
|
|
262
|
+
|
|
263
|
+
The default limit is 100,000 items (approximately 100MB for typical Person objects).
|
|
264
|
+
This protects against OOM errors when paginating large datasets.
|
|
265
|
+
|
|
266
|
+
To resolve:
|
|
267
|
+
- Use ``.pages()`` for streaming iteration (memory-efficient)
|
|
268
|
+
- Add filters to reduce result size
|
|
269
|
+
- Pass ``limit=None`` to ``.all()`` if you're certain you need all results
|
|
270
|
+
- Pass a custom ``limit=500_000`` if you need more than the default
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# =============================================================================
|
|
277
|
+
# URL Safety Errors
|
|
278
|
+
# =============================================================================
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class UnsafeUrlError(AffinityError):
|
|
282
|
+
"""
|
|
283
|
+
SDK blocked following a server-provided URL.
|
|
284
|
+
|
|
285
|
+
Raised when SafeFollowUrl policy rejects a URL (scheme/host/userinfo/redirect).
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
def __init__(self, message: str, *, url: str | None = None):
|
|
289
|
+
super().__init__(
|
|
290
|
+
message,
|
|
291
|
+
diagnostics=ErrorDiagnostics(url=url) if url else None,
|
|
292
|
+
)
|
|
293
|
+
self.url = url
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# =============================================================================
|
|
297
|
+
# Business Logic Errors
|
|
298
|
+
# =============================================================================
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class EntityNotFoundError(NotFoundError):
|
|
302
|
+
"""
|
|
303
|
+
Specific entity not found.
|
|
304
|
+
|
|
305
|
+
Provides type-safe context about which entity type was not found.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
def __init__(
|
|
309
|
+
self,
|
|
310
|
+
entity_type: str,
|
|
311
|
+
entity_id: int | str,
|
|
312
|
+
**kwargs: Any,
|
|
313
|
+
):
|
|
314
|
+
message = f"{entity_type} with ID {entity_id} not found"
|
|
315
|
+
super().__init__(message, **kwargs)
|
|
316
|
+
self.entity_type = entity_type
|
|
317
|
+
self.entity_id = entity_id
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class FieldNotFoundError(NotFoundError):
|
|
321
|
+
"""Field with the specified ID was not found."""
|
|
322
|
+
|
|
323
|
+
pass
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class ListNotFoundError(NotFoundError):
|
|
327
|
+
"""List with the specified ID was not found."""
|
|
328
|
+
|
|
329
|
+
pass
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class PersonNotFoundError(EntityNotFoundError):
|
|
333
|
+
"""Person with the specified ID was not found."""
|
|
334
|
+
|
|
335
|
+
def __init__(self, person_id: int, **kwargs: Any):
|
|
336
|
+
super().__init__("Person", person_id, **kwargs)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class CompanyNotFoundError(EntityNotFoundError):
|
|
340
|
+
"""Company with the specified ID was not found."""
|
|
341
|
+
|
|
342
|
+
def __init__(self, company_id: int, **kwargs: Any):
|
|
343
|
+
super().__init__("Company", company_id, **kwargs)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class OpportunityNotFoundError(EntityNotFoundError):
|
|
347
|
+
"""Opportunity with the specified ID was not found."""
|
|
348
|
+
|
|
349
|
+
def __init__(self, opportunity_id: int, **kwargs: Any):
|
|
350
|
+
super().__init__("Opportunity", opportunity_id, **kwargs)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# =============================================================================
|
|
354
|
+
# API Version Errors
|
|
355
|
+
# =============================================================================
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class UnsupportedOperationError(AffinityError):
|
|
359
|
+
"""
|
|
360
|
+
Operation not supported by the current API version.
|
|
361
|
+
|
|
362
|
+
Some operations are only available in V1 or V2.
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
pass
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class BetaEndpointDisabledError(UnsupportedOperationError):
|
|
369
|
+
"""Attempted to call a beta endpoint without opt-in."""
|
|
370
|
+
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class VersionCompatibilityError(AffinityError):
|
|
375
|
+
"""
|
|
376
|
+
Response shape mismatch suggests API version incompatibility.
|
|
377
|
+
|
|
378
|
+
TR-015: Raised when the SDK detects response-shape mismatches that
|
|
379
|
+
appear version-related. This typically means the API key's configured
|
|
380
|
+
v2 Default API Version differs from what the SDK expects.
|
|
381
|
+
|
|
382
|
+
Guidance:
|
|
383
|
+
1. Check your API key's v2 Default API Version in the Affinity dashboard
|
|
384
|
+
2. Ensure it matches the expected_v2_version configured in the SDK
|
|
385
|
+
3. See: https://developer.affinity.co/#section/Getting-Started/Versioning
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
def __init__(
|
|
389
|
+
self,
|
|
390
|
+
message: str,
|
|
391
|
+
*,
|
|
392
|
+
expected_version: str | None = None,
|
|
393
|
+
parsing_error: str | None = None,
|
|
394
|
+
status_code: int | None = None,
|
|
395
|
+
response_body: Any | None = None,
|
|
396
|
+
diagnostics: ErrorDiagnostics | None = None,
|
|
397
|
+
):
|
|
398
|
+
super().__init__(
|
|
399
|
+
message,
|
|
400
|
+
status_code=status_code,
|
|
401
|
+
response_body=response_body,
|
|
402
|
+
diagnostics=diagnostics,
|
|
403
|
+
)
|
|
404
|
+
self.expected_version = expected_version
|
|
405
|
+
self.parsing_error = parsing_error
|
|
406
|
+
|
|
407
|
+
def __str__(self) -> str:
|
|
408
|
+
base = super().__str__()
|
|
409
|
+
hints = []
|
|
410
|
+
if self.expected_version:
|
|
411
|
+
hints.append(f"expected_v2_version={self.expected_version}")
|
|
412
|
+
if self.parsing_error:
|
|
413
|
+
hints.append(f"parsing_error={self.parsing_error}")
|
|
414
|
+
if hints:
|
|
415
|
+
base = f"{base} ({', '.join(hints)})"
|
|
416
|
+
return base
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class DeprecationWarning(AffinityError):
|
|
420
|
+
"""
|
|
421
|
+
Feature is deprecated and may be removed.
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# =============================================================================
|
|
428
|
+
# Error Factory
|
|
429
|
+
# =============================================================================
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def error_from_response(
|
|
433
|
+
status_code: int,
|
|
434
|
+
response_body: Any,
|
|
435
|
+
*,
|
|
436
|
+
retry_after: int | None = None,
|
|
437
|
+
diagnostics: ErrorDiagnostics | None = None,
|
|
438
|
+
) -> AffinityError:
|
|
439
|
+
"""
|
|
440
|
+
Create the appropriate exception from an API error response.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
status_code: HTTP status code
|
|
444
|
+
response_body: Parsed response body (usually dict with 'errors')
|
|
445
|
+
retry_after: Retry-After header value for rate limits
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
Appropriate AffinityError subclass
|
|
449
|
+
"""
|
|
450
|
+
# Try to extract message from response
|
|
451
|
+
message = "Unknown error"
|
|
452
|
+
param = None
|
|
453
|
+
|
|
454
|
+
extracted = False
|
|
455
|
+
if isinstance(response_body, dict):
|
|
456
|
+
errors = response_body.get("errors")
|
|
457
|
+
if isinstance(errors, list) and errors:
|
|
458
|
+
for item in errors:
|
|
459
|
+
if isinstance(item, dict):
|
|
460
|
+
msg = item.get("message")
|
|
461
|
+
if isinstance(msg, str) and msg.strip():
|
|
462
|
+
message = msg.strip()
|
|
463
|
+
p = item.get("param")
|
|
464
|
+
if isinstance(p, str) and p.strip():
|
|
465
|
+
param = p
|
|
466
|
+
extracted = True
|
|
467
|
+
break
|
|
468
|
+
elif isinstance(item, str) and item.strip():
|
|
469
|
+
message = item.strip()
|
|
470
|
+
extracted = True
|
|
471
|
+
break
|
|
472
|
+
|
|
473
|
+
if not extracted:
|
|
474
|
+
top_message = response_body.get("message")
|
|
475
|
+
if isinstance(top_message, str) and top_message.strip():
|
|
476
|
+
message = top_message.strip()
|
|
477
|
+
extracted = True
|
|
478
|
+
else:
|
|
479
|
+
detail = response_body.get("detail")
|
|
480
|
+
if isinstance(detail, str) and detail.strip():
|
|
481
|
+
message = detail.strip()
|
|
482
|
+
extracted = True
|
|
483
|
+
else:
|
|
484
|
+
error_obj = response_body.get("error")
|
|
485
|
+
if isinstance(error_obj, dict):
|
|
486
|
+
nested_message = error_obj.get("message")
|
|
487
|
+
if isinstance(nested_message, str) and nested_message.strip():
|
|
488
|
+
message = nested_message.strip()
|
|
489
|
+
extracted = True
|
|
490
|
+
elif isinstance(error_obj, str) and error_obj.strip():
|
|
491
|
+
message = error_obj.strip()
|
|
492
|
+
extracted = True
|
|
493
|
+
|
|
494
|
+
if not extracted and isinstance(response_body, list) and response_body:
|
|
495
|
+
first = response_body[0]
|
|
496
|
+
if isinstance(first, dict):
|
|
497
|
+
msg = first.get("message") or first.get("error") or first.get("detail")
|
|
498
|
+
if isinstance(msg, str) and msg.strip():
|
|
499
|
+
message = msg.strip()
|
|
500
|
+
extracted = True
|
|
501
|
+
elif isinstance(first, str) and first.strip():
|
|
502
|
+
message = first.strip()
|
|
503
|
+
extracted = True
|
|
504
|
+
|
|
505
|
+
if (
|
|
506
|
+
message == "Unknown error"
|
|
507
|
+
and diagnostics is not None
|
|
508
|
+
and isinstance(diagnostics.response_body_snippet, str)
|
|
509
|
+
):
|
|
510
|
+
snippet = diagnostics.response_body_snippet.strip()
|
|
511
|
+
if snippet and snippet not in {"{}", "[]"}:
|
|
512
|
+
message = snippet
|
|
513
|
+
|
|
514
|
+
# Map status codes to exceptions
|
|
515
|
+
error_mapping: dict[int, type[AffinityError]] = {
|
|
516
|
+
400: ValidationError,
|
|
517
|
+
401: AuthenticationError,
|
|
518
|
+
403: AuthorizationError,
|
|
519
|
+
404: NotFoundError,
|
|
520
|
+
409: ConflictError,
|
|
521
|
+
422: ValidationError,
|
|
522
|
+
429: RateLimitError,
|
|
523
|
+
500: ServerError,
|
|
524
|
+
502: ServerError,
|
|
525
|
+
503: ServerError,
|
|
526
|
+
504: ServerError,
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
error_class = error_mapping.get(status_code, AffinityError)
|
|
530
|
+
|
|
531
|
+
# Special handling for ValidationError with param
|
|
532
|
+
if error_class is ValidationError:
|
|
533
|
+
return ValidationError(
|
|
534
|
+
message,
|
|
535
|
+
param=param,
|
|
536
|
+
status_code=status_code,
|
|
537
|
+
response_body=response_body,
|
|
538
|
+
diagnostics=diagnostics,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
# Special handling for RateLimitError with retry_after
|
|
542
|
+
if error_class is RateLimitError:
|
|
543
|
+
return RateLimitError(
|
|
544
|
+
message,
|
|
545
|
+
retry_after=retry_after,
|
|
546
|
+
status_code=status_code,
|
|
547
|
+
response_body=response_body,
|
|
548
|
+
diagnostics=diagnostics,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
return error_class(
|
|
552
|
+
message,
|
|
553
|
+
status_code=status_code,
|
|
554
|
+
response_body=response_body,
|
|
555
|
+
diagnostics=diagnostics,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
# =============================================================================
|
|
560
|
+
# Webhook parsing errors (inbound webhook helpers)
|
|
561
|
+
# =============================================================================
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
class WebhookParseError(AffinityError):
|
|
565
|
+
"""Base error for inbound webhook parsing/validation failures."""
|
|
566
|
+
|
|
567
|
+
pass
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
class WebhookInvalidJsonError(WebhookParseError):
|
|
571
|
+
"""Raised when a webhook payload cannot be decoded as JSON."""
|
|
572
|
+
|
|
573
|
+
pass
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
class WebhookInvalidPayloadError(WebhookParseError):
|
|
577
|
+
"""Raised when a decoded webhook payload is not in the expected envelope shape."""
|
|
578
|
+
|
|
579
|
+
pass
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
class WebhookMissingKeyError(WebhookParseError):
|
|
583
|
+
"""Raised when a webhook payload is missing a required key."""
|
|
584
|
+
|
|
585
|
+
def __init__(self, message: str, *, key: str):
|
|
586
|
+
super().__init__(message)
|
|
587
|
+
self.key = key
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
class WebhookInvalidSentAtError(WebhookParseError):
|
|
591
|
+
"""Raised when a webhook `sent_at` field is missing or invalid."""
|
|
592
|
+
|
|
593
|
+
pass
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
# =============================================================================
|
|
597
|
+
# Filter Parsing Errors
|
|
598
|
+
# =============================================================================
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
class FilterParseError(ValueError):
|
|
602
|
+
"""
|
|
603
|
+
Raised when a filter expression cannot be parsed.
|
|
604
|
+
|
|
605
|
+
Common causes:
|
|
606
|
+
- Multi-word values not quoted: Status=Intro Meeting
|
|
607
|
+
- Invalid operators
|
|
608
|
+
- Malformed expressions
|
|
609
|
+
|
|
610
|
+
Example fix:
|
|
611
|
+
# Wrong: --filter 'Status=Intro Meeting'
|
|
612
|
+
# Right: --filter 'Status="Intro Meeting"'
|
|
613
|
+
"""
|
|
614
|
+
|
|
615
|
+
pass
|