Skip to content

Exceptions

Custom exceptions for the Affinity API client.

All exceptions inherit from AffinityError for easy catching of all library errors.

AffinityError

Bases: Exception

Base exception for all Affinity API errors.

Source code in affinity/exceptions.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class AffinityError(Exception):
    """Base exception for all Affinity API errors."""

    def __init__(
        self,
        message: str,
        *,
        status_code: int | None = None,
        response_body: Any | None = None,
        diagnostics: ErrorDiagnostics | None = None,
    ):
        super().__init__(message)
        self.message = message
        self.status_code = status_code
        self.response_body = response_body
        self.diagnostics = diagnostics

    def __str__(self) -> str:
        base = self.message
        if self.status_code:
            base = f"[{self.status_code}] {base}"
        if self.diagnostics:
            # Include method + url if both present, or just url if only url present
            if self.diagnostics.method and self.diagnostics.url:
                base = f"{base} ({self.diagnostics.method} {self.diagnostics.url})"
            elif self.diagnostics.url:
                base = f"{base} (url={self.diagnostics.url})"
            if self.diagnostics.request_id:
                base = f"{base} [request_id={self.diagnostics.request_id}]"
        return base

AuthenticationError

Bases: AffinityError

401 Unauthorized - Invalid or missing API key.

Your API key is invalid or was not provided.

Source code in affinity/exceptions.py
64
65
66
67
68
69
70
71
class AuthenticationError(AffinityError):
    """
    401 Unauthorized - Invalid or missing API key.

    Your API key is invalid or was not provided.
    """

    pass

AuthorizationError

Bases: AffinityError

403 Forbidden - Insufficient permissions.

You don't have permission to access this resource or perform this action. This can happen with: - Private lists you don't have access to - Admin-only actions - Resource-level permission restrictions

Source code in affinity/exceptions.py
74
75
76
77
78
79
80
81
82
83
84
85
class AuthorizationError(AffinityError):
    """
    403 Forbidden - Insufficient permissions.

    You don't have permission to access this resource or perform this action.
    This can happen with:
    - Private lists you don't have access to
    - Admin-only actions
    - Resource-level permission restrictions
    """

    pass

BetaEndpointDisabledError

Bases: UnsupportedOperationError

Attempted to call a beta endpoint without opt-in.

Source code in affinity/exceptions.py
462
463
464
465
class BetaEndpointDisabledError(UnsupportedOperationError):
    """Attempted to call a beta endpoint without opt-in."""

    pass

CompanyMergedError

Bases: MergedEntityError

A company was merged into another company.

Source code in affinity/exceptions.py
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
class CompanyMergedError(MergedEntityError):
    """A company was merged into another company."""

    def __init__(
        self,
        message: str,
        *,
        source_id: int,
        target_id: int,
        **kwargs: Any,
    ):
        super().__init__(
            message,
            source_id=source_id,
            target_id=target_id,
            entity_type="Company",
            **kwargs,
        )

CompanyNotFoundError

Bases: EntityNotFoundError

Company with the specified ID was not found.

Source code in affinity/exceptions.py
340
341
342
343
344
class CompanyNotFoundError(EntityNotFoundError):
    """Company with the specified ID was not found."""

    def __init__(self, company_id: int, **kwargs: Any):
        super().__init__("Company", company_id, **kwargs)

ConfigurationError

Bases: AffinityError

Configuration error - missing or invalid client configuration.

Check that you've provided: - A valid API key - Correct base URLs (if customizing)

Source code in affinity/exceptions.py
191
192
193
194
195
196
197
198
199
200
class ConfigurationError(AffinityError):
    """
    Configuration error - missing or invalid client configuration.

    Check that you've provided:
    - A valid API key
    - Correct base URLs (if customizing)
    """

    pass

ConflictError

Bases: AffinityError

409 Conflict - Resource conflict.

The request conflicts with the current state of the resource. For example: - Trying to create a person with an email that already exists - Concurrent modification conflicts

Source code in affinity/exceptions.py
162
163
164
165
166
167
168
169
170
171
172
class ConflictError(AffinityError):
    """
    409 Conflict - Resource conflict.

    The request conflicts with the current state of the resource.
    For example:
    - Trying to create a person with an email that already exists
    - Concurrent modification conflicts
    """

    pass

DeprecationWarning

Bases: AffinityError

Feature is deprecated and may be removed.

Source code in affinity/exceptions.py
567
568
569
570
571
572
class DeprecationWarning(AffinityError):
    """
    Feature is deprecated and may be removed.
    """

    pass

DuplicateEntityError

Bases: AffinityError

Raised when an entity with the same identifying properties already exists and the caller requested duplicate prevention (if_not_exists=True).

Attributes:

Name Type Description
entity_type

"company", "person", or "opportunity"

existing_id

The ID of the already-existing entity

existing_name

Name of the existing entity (if known)

existing_domain

Domain of the existing entity (companies only)

existing_is_global

True iff the match is an Affinity global-directory record (companies only). Global records are shared across tenants and cannot be modified or deleted; callers should use the ID directly (e.g., add it to a list via List Entries) rather than POST a duplicate tenant-scoped record.

Source code in affinity/exceptions.py
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
class DuplicateEntityError(AffinityError):
    """
    Raised when an entity with the same identifying properties already exists
    and the caller requested duplicate prevention (if_not_exists=True).

    Attributes:
        entity_type: "company", "person", or "opportunity"
        existing_id: The ID of the already-existing entity
        existing_name: Name of the existing entity (if known)
        existing_domain: Domain of the existing entity (companies only)
        existing_is_global: True iff the match is an Affinity global-directory
            record (companies only). Global records are shared across tenants
            and cannot be modified or deleted; callers should use the ID
            directly (e.g., add it to a list via List Entries) rather than
            POST a duplicate tenant-scoped record.
    """

    def __init__(
        self,
        message: str,
        *,
        entity_type: str,
        existing_id: int,
        existing_name: str | None = None,
        existing_domain: str | None = None,
        existing_is_global: bool = False,
    ) -> None:
        super().__init__(message)
        self.entity_type = entity_type
        self.existing_id = existing_id
        self.existing_name = existing_name
        self.existing_domain = existing_domain
        self.existing_is_global = existing_is_global

EnrichedFieldNotWritableError

Bases: UnsupportedOperationError

Attempted to write to a V2 enriched field that has no V1 numeric twin.

V1 /field-values is the only write endpoint for entity-global field values. V2 enriched fields (e.g. affinity-data-current-organization, companies, and relationship-intelligence fields like last-contact) are returned by V2 reads but have no writable V1 counterpart.

Source code in affinity/exceptions.py
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
class EnrichedFieldNotWritableError(UnsupportedOperationError):
    """
    Attempted to write to a V2 enriched field that has no V1 numeric twin.

    V1 `/field-values` is the only write endpoint for entity-global field values.
    V2 enriched fields (e.g. ``affinity-data-current-organization``, ``companies``,
    and relationship-intelligence fields like ``last-contact``) are returned by V2
    reads but have no writable V1 counterpart.
    """

    def __init__(self, field_id: str, *, reason: str | None = None) -> None:
        self.field_id = field_id
        self.reason = reason
        msg = f"Enriched field '{field_id}' is not writable via API"
        if reason:
            msg += f" ({reason})"
        super().__init__(msg)

EntityNotFoundError

Bases: NotFoundError

Specific entity not found.

Provides type-safe context about which entity type was not found.

Source code in affinity/exceptions.py
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
class EntityNotFoundError(NotFoundError):
    """
    Specific entity not found.

    Provides type-safe context about which entity type was not found.
    """

    def __init__(
        self,
        entity_type: str,
        entity_id: int | str,
        **kwargs: Any,
    ):
        message = f"{entity_type} with ID {entity_id} not found"
        super().__init__(message, **kwargs)
        self.entity_type = entity_type
        self.entity_id = entity_id

FieldNotFoundError

Bases: NotFoundError

Field with the specified ID was not found.

Source code in affinity/exceptions.py
321
322
323
324
class FieldNotFoundError(NotFoundError):
    """Field with the specified ID was not found."""

    pass

FilterParseError

Bases: ValueError

Raised when a filter expression cannot be parsed.

Common causes: - Multi-word values not quoted: Status=Intro Meeting - Invalid operators - Malformed expressions

Example fix

Wrong: --filter 'Status=Intro Meeting'

Right: --filter 'Status="Intro Meeting"'

Source code in affinity/exceptions.py
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
class FilterParseError(ValueError):
    """
    Raised when a filter expression cannot be parsed.

    Common causes:
    - Multi-word values not quoted: Status=Intro Meeting
    - Invalid operators
    - Malformed expressions

    Example fix:
        # Wrong: --filter 'Status=Intro Meeting'
        # Right: --filter 'Status="Intro Meeting"'
    """

    pass

ListNotFoundError

Bases: NotFoundError

List with the specified ID was not found.

Source code in affinity/exceptions.py
327
328
329
330
class ListNotFoundError(NotFoundError):
    """List with the specified ID was not found."""

    pass

MergedEntityError

Bases: ValidationError

422 error indicating an entity was merged into another.

When accessing a company or person that has been merged, the Affinity API returns a 422 with a message like: "292479388 no longer exists as it has been merged into 301128758"

This exception provides structured access to both IDs.

Attributes:

Name Type Description
source_id

The ID of the entity that was merged away (no longer exists).

target_id

The ID of the entity it was merged into (the surviving entity).

entity_type

The type of entity ("Company", "Person", or None if unknown).

Source code in affinity/exceptions.py
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
class MergedEntityError(ValidationError):
    """
    422 error indicating an entity was merged into another.

    When accessing a company or person that has been merged, the Affinity API
    returns a 422 with a message like:
    "292479388 no longer exists as it has been merged into 301128758"

    This exception provides structured access to both IDs.

    Attributes:
        source_id: The ID of the entity that was merged away (no longer exists).
        target_id: The ID of the entity it was merged into (the surviving entity).
        entity_type: The type of entity ("Company", "Person", or None if unknown).
    """

    def __init__(
        self,
        message: str,
        *,
        source_id: int,
        target_id: int,
        entity_type: str | None = None,
        param: str | None = None,
        status_code: int | None = None,
        response_body: Any | None = None,
        diagnostics: ErrorDiagnostics | None = None,
    ):
        super().__init__(
            message,
            param=param,
            status_code=status_code,
            response_body=response_body,
            diagnostics=diagnostics,
        )
        self.source_id = source_id
        self.target_id = target_id
        self.entity_type = entity_type

    @staticmethod
    def match(message: str) -> re.Match[str] | None:
        """Check if an error message matches the merged entity pattern."""
        return _MERGED_PATTERN.search(message)

match(message: str) -> re.Match[str] | None staticmethod

Check if an error message matches the merged entity pattern.

Source code in affinity/exceptions.py
401
402
403
404
@staticmethod
def match(message: str) -> re.Match[str] | None:
    """Check if an error message matches the merged entity pattern."""
    return _MERGED_PATTERN.search(message)

NetworkError

Bases: AffinityError

Network-level error.

Failed to connect to the Affinity API. Check your internet connection and firewall settings.

Source code in affinity/exceptions.py
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
class NetworkError(AffinityError):
    """
    Network-level error.

    Failed to connect to the Affinity API.
    Check your internet connection and firewall settings.
    """

    def __init__(
        self,
        message: str,
        *,
        diagnostics: ErrorDiagnostics | None = None,
    ):
        super().__init__(message, diagnostics=diagnostics)

NotFoundError

Bases: AffinityError

404 Not Found - Resource doesn't exist.

The requested resource (person, company, list, etc.) was not found. This could mean: - The ID is invalid - The resource was deleted - You don't have access to view it

Source code in affinity/exceptions.py
88
89
90
91
92
93
94
95
96
97
98
99
class NotFoundError(AffinityError):
    """
    404 Not Found - Resource doesn't exist.

    The requested resource (person, company, list, etc.) was not found.
    This could mean:
    - The ID is invalid
    - The resource was deleted
    - You don't have access to view it
    """

    pass

OpportunityNotFoundError

Bases: EntityNotFoundError

Opportunity with the specified ID was not found.

Source code in affinity/exceptions.py
347
348
349
350
351
class OpportunityNotFoundError(EntityNotFoundError):
    """Opportunity with the specified ID was not found."""

    def __init__(self, opportunity_id: int, **kwargs: Any):
        super().__init__("Opportunity", opportunity_id, **kwargs)

PersonMergedError

Bases: MergedEntityError

A person was merged into another person.

Source code in affinity/exceptions.py
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
class PersonMergedError(MergedEntityError):
    """A person was merged into another person."""

    def __init__(
        self,
        message: str,
        *,
        source_id: int,
        target_id: int,
        **kwargs: Any,
    ):
        super().__init__(
            message,
            source_id=source_id,
            target_id=target_id,
            entity_type="Person",
            **kwargs,
        )

PersonNotFoundError

Bases: EntityNotFoundError

Person with the specified ID was not found.

Source code in affinity/exceptions.py
333
334
335
336
337
class PersonNotFoundError(EntityNotFoundError):
    """Person with the specified ID was not found."""

    def __init__(self, person_id: int, **kwargs: Any):
        super().__init__("Person", person_id, **kwargs)

PolicyError

Bases: AffinityError

Raised when a client policy blocks an attempted operation.

Source code in affinity/exceptions.py
240
241
242
243
class PolicyError(AffinityError):
    """Raised when a client policy blocks an attempted operation."""

    pass

RateLimitError

Bases: AffinityError

429 Too Many Requests - Rate limit exceeded.

You've exceeded the API rate limit. Wait before retrying. Check the response headers for rate limit info: - X-Ratelimit-Limit-User-Reset: Seconds until per-minute limit resets - X-Ratelimit-Limit-Org-Reset: Seconds until monthly limit resets

Source code in affinity/exceptions.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
class RateLimitError(AffinityError):
    """
    429 Too Many Requests - Rate limit exceeded.

    You've exceeded the API rate limit. Wait before retrying.
    Check the response headers for rate limit info:
    - X-Ratelimit-Limit-User-Reset: Seconds until per-minute limit resets
    - X-Ratelimit-Limit-Org-Reset: Seconds until monthly limit resets
    """

    def __init__(
        self,
        message: str,
        *,
        retry_after: int | None = None,
        status_code: int | None = None,
        response_body: Any | None = None,
        diagnostics: ErrorDiagnostics | None = None,
    ):
        super().__init__(
            message,
            status_code=status_code,
            response_body=response_body,
            diagnostics=diagnostics,
        )
        self.retry_after = retry_after

ServerError

Bases: AffinityError

500/503 Internal Server Error - Server-side problem.

Something went wrong on Affinity's servers. Try again later, and contact support if the problem persists.

Source code in affinity/exceptions.py
175
176
177
178
179
180
181
182
183
class ServerError(AffinityError):
    """
    500/503 Internal Server Error - Server-side problem.

    Something went wrong on Affinity's servers.
    Try again later, and contact support if the problem persists.
    """

    pass

TimeoutError

Bases: AffinityError

Request timeout.

The request took too long to complete. This could be due to: - Network issues - Large data sets - Server overload

Source code in affinity/exceptions.py
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
class TimeoutError(AffinityError):
    """
    Request timeout.

    The request took too long to complete.
    This could be due to:
    - Network issues
    - Large data sets
    - Server overload
    """

    def __init__(
        self,
        message: str,
        *,
        diagnostics: ErrorDiagnostics | None = None,
    ):
        super().__init__(message, diagnostics=diagnostics)

TooManyResultsError

Bases: AffinityError

Raised when .all() exceeds the limit.

The default limit is 100,000 items (approximately 100MB for typical Person objects). This protects against OOM errors when paginating large datasets.

To resolve: - Use .pages() for streaming iteration (memory-efficient) - Add filters to reduce result size - Pass limit=None to .all() if you're certain you need all results - Pass a custom limit=500_000 if you need more than the default

Source code in affinity/exceptions.py
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
class TooManyResultsError(AffinityError):
    """
    Raised when ``.all()`` exceeds the limit.

    The default limit is 100,000 items (approximately 100MB for typical Person objects).
    This protects against OOM errors when paginating large datasets.

    To resolve:
    - Use ``.pages()`` for streaming iteration (memory-efficient)
    - Add filters to reduce result size
    - Pass ``limit=None`` to ``.all()`` if you're certain you need all results
    - Pass a custom ``limit=500_000`` if you need more than the default
    """

    pass

UnsafeUrlError

Bases: AffinityError

SDK blocked following a server-provided URL.

Raised when SafeFollowUrl policy rejects a URL (scheme/host/userinfo/redirect).

Source code in affinity/exceptions.py
282
283
284
285
286
287
288
289
290
291
292
293
294
class UnsafeUrlError(AffinityError):
    """
    SDK blocked following a server-provided URL.

    Raised when SafeFollowUrl policy rejects a URL (scheme/host/userinfo/redirect).
    """

    def __init__(self, message: str, *, url: str | None = None):
        super().__init__(
            message,
            diagnostics=ErrorDiagnostics(url=url) if url else None,
        )
        self.url = url

UnsupportedOperationError

Bases: AffinityError

Operation not supported by the current API version.

Some operations are only available in V1 or V2.

Source code in affinity/exceptions.py
452
453
454
455
456
457
458
459
class UnsupportedOperationError(AffinityError):
    """
    Operation not supported by the current API version.

    Some operations are only available in V1 or V2.
    """

    pass

ValidationError

Bases: AffinityError

400/422 Bad Request/Unprocessable Entity - Invalid request data.

The request data is malformed or logically invalid. Check the error message for details about what's wrong.

Source code in affinity/exceptions.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
class ValidationError(AffinityError):
    """
    400/422 Bad Request/Unprocessable Entity - Invalid request data.

    The request data is malformed or logically invalid.
    Check the error message for details about what's wrong.
    """

    def __init__(
        self,
        message: str,
        *,
        param: str | None = None,
        status_code: int | None = None,
        response_body: Any | None = None,
        diagnostics: ErrorDiagnostics | None = None,
    ):
        super().__init__(
            message,
            status_code=status_code,
            response_body=response_body,
            diagnostics=diagnostics,
        )
        self.param = param

    def __str__(self) -> str:
        base = super().__str__()
        if self.param:
            return f"{base} (param: {self.param})"
        return base

VersionCompatibilityError

Bases: AffinityError

Response shape mismatch suggests API version incompatibility.

TR-015: Raised when the SDK detects response-shape mismatches that appear version-related. This typically means the API key's configured v2 Default API Version differs from what the SDK expects.

Guidance: 1. Check your API key's v2 Default API Version in the Affinity dashboard 2. Ensure it matches the expected_v2_version configured in the SDK 3. See: https://developer.affinity.co/#section/Getting-Started/Versioning

Source code in affinity/exceptions.py
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
class VersionCompatibilityError(AffinityError):
    """
    Response shape mismatch suggests API version incompatibility.

    TR-015: Raised when the SDK detects response-shape mismatches that
    appear version-related. This typically means the API key's configured
    v2 Default API Version differs from what the SDK expects.

    Guidance:
    1. Check your API key's v2 Default API Version in the Affinity dashboard
    2. Ensure it matches the expected_v2_version configured in the SDK
    3. See: https://developer.affinity.co/#section/Getting-Started/Versioning
    """

    def __init__(
        self,
        message: str,
        *,
        expected_version: str | None = None,
        parsing_error: str | None = None,
        status_code: int | None = None,
        response_body: Any | None = None,
        diagnostics: ErrorDiagnostics | None = None,
    ):
        super().__init__(
            message,
            status_code=status_code,
            response_body=response_body,
            diagnostics=diagnostics,
        )
        self.expected_version = expected_version
        self.parsing_error = parsing_error

    def __str__(self) -> str:
        base = super().__str__()
        hints = []
        if self.expected_version:
            hints.append(f"expected_v2_version={self.expected_version}")
        if self.parsing_error:
            hints.append(f"parsing_error={self.parsing_error}")
        if hints:
            base = f"{base} ({', '.join(hints)})"
        return base

WebhookInvalidJsonError

Bases: WebhookParseError

Raised when a webhook payload cannot be decoded as JSON.

Source code in affinity/exceptions.py
749
750
751
752
class WebhookInvalidJsonError(WebhookParseError):
    """Raised when a webhook payload cannot be decoded as JSON."""

    pass

WebhookInvalidPayloadError

Bases: WebhookParseError

Raised when a decoded webhook payload is not in the expected envelope shape.

Source code in affinity/exceptions.py
755
756
757
758
class WebhookInvalidPayloadError(WebhookParseError):
    """Raised when a decoded webhook payload is not in the expected envelope shape."""

    pass

WebhookInvalidSentAtError

Bases: WebhookParseError

Raised when a webhook sent_at field is missing or invalid.

Source code in affinity/exceptions.py
769
770
771
772
class WebhookInvalidSentAtError(WebhookParseError):
    """Raised when a webhook `sent_at` field is missing or invalid."""

    pass

WebhookMissingKeyError

Bases: WebhookParseError

Raised when a webhook payload is missing a required key.

Source code in affinity/exceptions.py
761
762
763
764
765
766
class WebhookMissingKeyError(WebhookParseError):
    """Raised when a webhook payload is missing a required key."""

    def __init__(self, message: str, *, key: str):
        super().__init__(message)
        self.key = key

WebhookParseError

Bases: AffinityError

Base error for inbound webhook parsing/validation failures.

Source code in affinity/exceptions.py
743
744
745
746
class WebhookParseError(AffinityError):
    """Base error for inbound webhook parsing/validation failures."""

    pass

WriteNotAllowedError

Bases: PolicyError

Raised when a write operation is attempted while the write policy denies writes.

Source code in affinity/exceptions.py
246
247
248
249
250
251
252
class WriteNotAllowedError(PolicyError):
    """Raised when a write operation is attempted while the write policy denies writes."""

    def __init__(self, message: str, *, method: str, url: str):
        super().__init__(message, diagnostics=ErrorDiagnostics(method=method, url=url))
        self.method = method
        self.url = url

error_from_response(status_code: int, response_body: Any, *, retry_after: int | None = None, diagnostics: ErrorDiagnostics | None = None) -> AffinityError

Create the appropriate exception from an API error response.

Parameters:

Name Type Description Default
status_code int

HTTP status code

required
response_body Any

Parsed response body (usually dict with 'errors')

required
retry_after int | None

Retry-After header value for rate limits

None

Returns:

Type Description
AffinityError

Appropriate AffinityError subclass

Source code in affinity/exceptions.py
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
def error_from_response(
    status_code: int,
    response_body: Any,
    *,
    retry_after: int | None = None,
    diagnostics: ErrorDiagnostics | None = None,
) -> AffinityError:
    """
    Create the appropriate exception from an API error response.

    Args:
        status_code: HTTP status code
        response_body: Parsed response body (usually dict with 'errors')
        retry_after: Retry-After header value for rate limits

    Returns:
        Appropriate AffinityError subclass
    """
    # Try to extract message from response
    message = "Unknown error"
    param = None

    extracted = False
    if isinstance(response_body, dict):
        errors = response_body.get("errors")
        if isinstance(errors, list) and errors:
            for item in errors:
                if isinstance(item, dict):
                    msg = item.get("message")
                    if isinstance(msg, str) and msg.strip():
                        message = msg.strip()
                        p = item.get("param")
                        if isinstance(p, str) and p.strip():
                            param = p
                        extracted = True
                        break
                elif isinstance(item, str) and item.strip():
                    message = item.strip()
                    extracted = True
                    break

        if not extracted:
            top_message = response_body.get("message")
            if isinstance(top_message, str) and top_message.strip():
                message = top_message.strip()
                extracted = True
            else:
                detail = response_body.get("detail")
                if isinstance(detail, str) and detail.strip():
                    message = detail.strip()
                    extracted = True
                else:
                    error_obj = response_body.get("error")
                    if isinstance(error_obj, dict):
                        nested_message = error_obj.get("message")
                        if isinstance(nested_message, str) and nested_message.strip():
                            message = nested_message.strip()
                            extracted = True
                    elif isinstance(error_obj, str) and error_obj.strip():
                        message = error_obj.strip()
                        extracted = True

    if not extracted and isinstance(response_body, list) and response_body:
        first = response_body[0]
        if isinstance(first, dict):
            msg = first.get("message") or first.get("error") or first.get("detail")
            if isinstance(msg, str) and msg.strip():
                message = msg.strip()
                extracted = True
        elif isinstance(first, str) and first.strip():
            message = first.strip()
            extracted = True

    if (
        message == "Unknown error"
        and diagnostics is not None
        and isinstance(diagnostics.response_body_snippet, str)
    ):
        snippet = diagnostics.response_body_snippet.strip()
        if snippet and snippet not in {"{}", "[]"}:
            message = snippet

    # Map status codes to exceptions
    error_mapping: dict[int, type[AffinityError]] = {
        400: ValidationError,
        401: AuthenticationError,
        403: AuthorizationError,
        404: NotFoundError,
        409: ConflictError,
        422: ValidationError,
        429: RateLimitError,
        500: ServerError,
        502: ServerError,
        503: ServerError,
        504: ServerError,
    }

    error_class = error_mapping.get(status_code, AffinityError)

    # Special handling for ValidationError with param
    if error_class is ValidationError:
        # Check if this is a merged entity error (422 with merge message)
        merge_match = MergedEntityError.match(message)
        if merge_match is not None:
            source_id = int(merge_match.group(1))
            target_id = int(merge_match.group(2))
            # Detect entity type from URL in diagnostics
            entity_type: str | None = None
            if diagnostics is not None and diagnostics.url is not None:
                url = diagnostics.url
                if "/companies/" in url or "/organizations/" in url:
                    entity_type = "Company"
                elif "/persons/" in url or "/people/" in url:
                    entity_type = "Person"

            if entity_type == "Company":
                cls: type[MergedEntityError] = CompanyMergedError
            elif entity_type == "Person":
                cls = PersonMergedError
            else:
                cls = MergedEntityError

            return cls(
                message,
                source_id=source_id,
                target_id=target_id,
                param=param,
                status_code=status_code,
                response_body=response_body,
                diagnostics=diagnostics,
            )

        return ValidationError(
            message,
            param=param,
            status_code=status_code,
            response_body=response_body,
            diagnostics=diagnostics,
        )

    # Special handling for RateLimitError with retry_after
    if error_class is RateLimitError:
        return RateLimitError(
            message,
            retry_after=retry_after,
            status_code=status_code,
            response_body=response_body,
            diagnostics=diagnostics,
        )

    return error_class(
        message,
        status_code=status_code,
        response_body=response_body,
        diagnostics=diagnostics,
    )