From ea21aade1663e6cf42cd75c21566b813adbd2916 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Dec 2025 14:00:11 +0000 Subject: [PATCH 1/2] Initial plan From 1d68ed7dd8b1aa781887af905cc433640f77f3b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Dec 2025 14:09:03 +0000 Subject: [PATCH 2/2] Refactor duplicated code - extract shared utilities and functions Co-authored-by: domfahey <789732+domfahey@users.noreply.github.com> --- scripts/flag_duplicates.py | 20 +- scripts/resolve_duplicates.py | 21 +- src/dex_python/async_client.py | 85 +------- src/dex_python/client.py | 83 +------- src/dex_python/client_utils.py | 89 +++++++++ src/dex_python/deduplication.py | 28 +++ .../deduplication/test_find_all_duplicates.py | 182 ++++++++++++++++++ tests/unit/test_client_utils.py | 121 ++++++++++++ 8 files changed, 440 insertions(+), 189 deletions(-) create mode 100644 src/dex_python/client_utils.py create mode 100644 tests/unit/deduplication/test_find_all_duplicates.py create mode 100644 tests/unit/test_client_utils.py diff --git a/scripts/flag_duplicates.py b/scripts/flag_duplicates.py index 05886d6..e6c7e12 100644 --- a/scripts/flag_duplicates.py +++ b/scripts/flag_duplicates.py @@ -5,13 +5,7 @@ import uuid from pathlib import Path -from dex_python.deduplication import ( - cluster_duplicates, - find_email_duplicates, - find_fuzzy_name_duplicates, - find_name_and_title_duplicates, - find_phone_duplicates, -) +from dex_python.deduplication import find_all_duplicates DATA_DIR = Path(os.getenv("DEX_DATA_DIR", "output")) DEFAULT_DB_PATH = DATA_DIR / "dex_contacts.db" @@ -38,18 +32,10 @@ def main(db_path: str = str(DEFAULT_DB_PATH)) -> None: print("Finding all potential duplicates...") - # Collect all signals - matches = [] - matches.extend(find_email_duplicates(conn)) - matches.extend(find_phone_duplicates(conn)) - matches.extend(find_name_and_title_duplicates(conn)) - # Consistent threshold with previous run - matches.extend(find_fuzzy_name_duplicates(conn, threshold=0.98)) + # Use shared duplicate detection function + matches, clusters = find_all_duplicates(conn, fuzzy_threshold=0.98) print(f"Found {len(matches)} duplicate signals.") - - # Cluster into entities - clusters = cluster_duplicates(matches) print(f"Clustered into {len(clusters)} unique duplicate groups.") if not clusters: diff --git a/scripts/resolve_duplicates.py b/scripts/resolve_duplicates.py index 34ca647..6ced940 100644 --- a/scripts/resolve_duplicates.py +++ b/scripts/resolve_duplicates.py @@ -4,14 +4,7 @@ import sqlite3 from pathlib import Path -from dex_python.deduplication import ( - cluster_duplicates, - find_email_duplicates, - find_fuzzy_name_duplicates, - find_name_and_title_duplicates, - find_phone_duplicates, - merge_cluster, -) +from dex_python.deduplication import find_all_duplicates, merge_cluster DATA_DIR = Path(os.getenv("DEX_DATA_DIR", "output")) DEFAULT_DB_PATH = DATA_DIR / "dex_contacts.db" @@ -26,18 +19,10 @@ def main(db_path: str = str(DEFAULT_DB_PATH)) -> None: print("Finding all potential duplicates...") - # Collect all signals - matches = [] - matches.extend(find_email_duplicates(conn)) - matches.extend(find_phone_duplicates(conn)) - matches.extend(find_name_and_title_duplicates(conn)) - # Using a very high threshold for auto-merging fuzzy matches - matches.extend(find_fuzzy_name_duplicates(conn, threshold=0.98)) + # Use shared duplicate detection function + matches, clusters = find_all_duplicates(conn, fuzzy_threshold=0.98) print(f"Found {len(matches)} duplicate signals.") - - # Cluster into entities - clusters = cluster_duplicates(matches) print(f"Clustered into {len(clusters)} unique entities to be merged.") if not clusters: diff --git a/src/dex_python/async_client.py b/src/dex_python/async_client.py index 8a98b25..20f108e 100644 --- a/src/dex_python/async_client.py +++ b/src/dex_python/async_client.py @@ -20,16 +20,8 @@ import httpx +from .client_utils import handle_error, should_retry from .config import Settings -from .exceptions import ( - AuthenticationError, - ContactNotFoundError, - DexAPIError, - NoteNotFoundError, - RateLimitError, - ReminderNotFoundError, - ValidationError, -) from .models import ( ContactCreate, ContactUpdate, @@ -47,9 +39,6 @@ extract_reminders_total, ) -# HTTP status codes that indicate transient failures worth retrying -RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504} - class AsyncDexClient: """Asynchronous client for the Dex CRM API. @@ -94,66 +83,6 @@ def __init__( timeout=30.0, ) - def _should_retry(self, status_code: int) -> bool: - """Check if a request should be retried based on HTTP status code.""" - return status_code in RETRYABLE_STATUS_CODES - - def _handle_error(self, response: httpx.Response, endpoint: str) -> None: - """Convert HTTP error response to appropriate exception. - - Args: - response: The HTTP response with error status. - endpoint: The API endpoint that was called. - - Raises: - AuthenticationError: For 401 responses. - RateLimitError: For 429 responses. - ValidationError: For 400 responses. - ContactNotFoundError: For 404 on /contacts endpoints. - ReminderNotFoundError: For 404 on /reminders endpoints. - NoteNotFoundError: For 404 on /timeline_items endpoints. - DexAPIError: For all other error responses. - """ - status_code = response.status_code - try: - data = response.json() - except Exception: - data = {} - - if status_code == 401: - raise AuthenticationError( - "Invalid API key", status_code=401, response_data=data - ) - elif status_code == 429: - retry_after = response.headers.get("Retry-After") - raise RateLimitError( - "Rate limit exceeded", - retry_after=int(retry_after) if retry_after else None, - ) - elif status_code == 400: - raise ValidationError( - data.get("error", "Validation error"), - status_code=400, - response_data=data, - ) - elif status_code == 404: - if "/contacts/" in endpoint: - contact_id = endpoint.split("/contacts/")[-1].split("/")[0] - raise ContactNotFoundError(contact_id) - elif "/reminders/" in endpoint: - reminder_id = endpoint.split("/reminders/")[-1].split("/")[0] - raise ReminderNotFoundError(reminder_id) - elif "/timeline_items/" in endpoint: - note_id = endpoint.split("/timeline_items/")[-1].split("/")[0] - raise NoteNotFoundError(note_id) - raise DexAPIError("Not found", status_code=404, response_data=data) - else: - raise DexAPIError( - data.get("error", f"API error: {status_code}"), - status_code=status_code, - response_data=data, - ) - async def _request_with_retry( self, method: str, endpoint: str, **kwargs: Any ) -> httpx.Response: @@ -177,7 +106,7 @@ async def _request_with_retry( return response is_last_attempt = attempt == self.max_retries - if not self._should_retry(response.status_code) or is_last_attempt: + if not should_retry(response.status_code) or is_last_attempt: return response # Exponential backoff @@ -206,7 +135,7 @@ async def _request( """ response = await self._request_with_retry(method, endpoint, **kwargs) if response.status_code >= 400: - self._handle_error(response, endpoint) + handle_error(response, endpoint) result: dict[str, Any] = response.json() return result @@ -233,7 +162,7 @@ async def get_contacts( params={"limit": limit, "offset": offset}, ) if response.status_code >= 400: - self._handle_error(response, endpoint) + handle_error(response, endpoint) data: dict[str, Any] = response.json() result: list[dict[str, Any]] = data.get("contacts", []) return result @@ -257,7 +186,7 @@ async def get_contacts_paginated( params={"limit": limit, "offset": offset}, ) if response.status_code >= 400: - self._handle_error(response, endpoint) + handle_error(response, endpoint) data: dict[str, Any] = response.json() return PaginatedContacts( contacts=data.get("contacts", []), @@ -386,7 +315,7 @@ async def get_reminders_paginated( params={"limit": limit, "offset": offset}, ) if response.status_code >= 400: - self._handle_error(response, endpoint) + handle_error(response, endpoint) data: dict[str, Any] = response.json() return PaginatedReminders( reminders=data.get("reminders", []), @@ -484,7 +413,7 @@ async def get_notes_paginated( params={"limit": limit, "offset": offset}, ) if response.status_code >= 400: - self._handle_error(response, endpoint) + handle_error(response, endpoint) data: dict[str, Any] = response.json() return PaginatedNotes( notes=data.get("timeline_items", []), diff --git a/src/dex_python/client.py b/src/dex_python/client.py index b1c9ca9..1585b80 100644 --- a/src/dex_python/client.py +++ b/src/dex_python/client.py @@ -20,16 +20,8 @@ import httpx +from .client_utils import handle_error, should_retry from .config import Settings -from .exceptions import ( - AuthenticationError, - ContactNotFoundError, - DexAPIError, - NoteNotFoundError, - RateLimitError, - ReminderNotFoundError, - ValidationError, -) from .models import ( ContactCreate, ContactUpdate, @@ -47,9 +39,6 @@ extract_reminders_total, ) -# HTTP status codes that indicate transient failures worth retrying -RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504} - class DexClient: """Synchronous client for the Dex CRM API. @@ -94,65 +83,7 @@ def __init__( timeout=30.0, ) - def _handle_error(self, response: httpx.Response, endpoint: str) -> None: - """Convert HTTP error response to appropriate exception. - - Args: - response: The HTTP response with error status. - endpoint: The API endpoint that was called. - Raises: - AuthenticationError: For 401 responses. - RateLimitError: For 429 responses. - ValidationError: For 400 responses. - ContactNotFoundError: For 404 on /contacts endpoints. - ReminderNotFoundError: For 404 on /reminders endpoints. - NoteNotFoundError: For 404 on /timeline_items endpoints. - DexAPIError: For all other error responses. - """ - status_code = response.status_code - try: - data = response.json() - except Exception: - data = {} - - if status_code == 401: - raise AuthenticationError( - "Invalid API key", status_code=401, response_data=data - ) - elif status_code == 429: - retry_after = response.headers.get("Retry-After") - raise RateLimitError( - "Rate limit exceeded", - retry_after=int(retry_after) if retry_after else None, - ) - elif status_code == 400: - raise ValidationError( - data.get("error", "Validation error"), - status_code=400, - response_data=data, - ) - elif status_code == 404: - if "/contacts/" in endpoint: - contact_id = endpoint.split("/contacts/")[-1].split("/")[0] - raise ContactNotFoundError(contact_id) - elif "/reminders/" in endpoint: - reminder_id = endpoint.split("/reminders/")[-1].split("/")[0] - raise ReminderNotFoundError(reminder_id) - elif "/timeline_items/" in endpoint: - note_id = endpoint.split("/timeline_items/")[-1].split("/")[0] - raise NoteNotFoundError(note_id) - raise DexAPIError("Not found", status_code=404, response_data=data) - else: - raise DexAPIError( - data.get("error", f"API error: {status_code}"), - status_code=status_code, - response_data=data, - ) - - def _should_retry(self, status_code: int) -> bool: - """Check if a request should be retried based on HTTP status code.""" - return status_code in RETRYABLE_STATUS_CODES def _request_with_retry( self, method: str, endpoint: str, **kwargs: Any @@ -177,7 +108,7 @@ def _request_with_retry( return response is_last_attempt = attempt == self.max_retries - if not self._should_retry(response.status_code) or is_last_attempt: + if not should_retry(response.status_code) or is_last_attempt: return response # Exponential backoff @@ -204,7 +135,7 @@ def _request(self, method: str, endpoint: str, **kwargs: Any) -> dict[str, Any]: """ response = self._request_with_retry(method, endpoint, **kwargs) if response.status_code >= 400: - self._handle_error(response, endpoint) + handle_error(response, endpoint) result: dict[str, Any] = response.json() return result @@ -229,7 +160,7 @@ def get_contacts(self, limit: int = 100, offset: int = 0) -> list[dict[str, Any] params={"limit": limit, "offset": offset}, ) if response.status_code >= 400: - self._handle_error(response, endpoint) + handle_error(response, endpoint) data: dict[str, Any] = response.json() result: list[dict[str, Any]] = data.get("contacts", []) return result @@ -255,7 +186,7 @@ def get_contacts_paginated( params={"limit": limit, "offset": offset}, ) if response.status_code >= 400: - self._handle_error(response, endpoint) + handle_error(response, endpoint) data: dict[str, Any] = response.json() return PaginatedContacts( contacts=data.get("contacts", []), @@ -382,7 +313,7 @@ def get_reminders_paginated( params={"limit": limit, "offset": offset}, ) if response.status_code >= 400: - self._handle_error(response, endpoint) + handle_error(response, endpoint) data: dict[str, Any] = response.json() return PaginatedReminders( reminders=data.get("reminders", []), @@ -476,7 +407,7 @@ def get_notes_paginated(self, limit: int = 100, offset: int = 0) -> PaginatedNot params={"limit": limit, "offset": offset}, ) if response.status_code >= 400: - self._handle_error(response, endpoint) + handle_error(response, endpoint) data: dict[str, Any] = response.json() return PaginatedNotes( notes=data.get("timeline_items", []), diff --git a/src/dex_python/client_utils.py b/src/dex_python/client_utils.py new file mode 100644 index 0000000..c36c355 --- /dev/null +++ b/src/dex_python/client_utils.py @@ -0,0 +1,89 @@ +"""Shared utilities for DexClient and AsyncDexClient. + +This module contains common functionality used by both synchronous and +asynchronous API clients to reduce code duplication. +""" + +import httpx + +from .exceptions import ( + AuthenticationError, + ContactNotFoundError, + DexAPIError, + NoteNotFoundError, + RateLimitError, + ReminderNotFoundError, + ValidationError, +) + +# HTTP status codes that indicate transient failures worth retrying +RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504} + + +def should_retry(status_code: int) -> bool: + """Check if a request should be retried based on HTTP status code. + + Args: + status_code: The HTTP status code from the response. + + Returns: + True if the request should be retried, False otherwise. + """ + return status_code in RETRYABLE_STATUS_CODES + + +def handle_error(response: httpx.Response, endpoint: str) -> None: + """Convert HTTP error response to appropriate exception. + + Args: + response: The HTTP response with error status. + endpoint: The API endpoint that was called. + + Raises: + AuthenticationError: For 401 responses. + RateLimitError: For 429 responses. + ValidationError: For 400 responses. + ContactNotFoundError: For 404 on /contacts endpoints. + ReminderNotFoundError: For 404 on /reminders endpoints. + NoteNotFoundError: For 404 on /timeline_items endpoints. + DexAPIError: For all other error responses. + """ + status_code = response.status_code + try: + data = response.json() + except Exception: + data = {} + + if status_code == 401: + raise AuthenticationError( + "Invalid API key", status_code=401, response_data=data + ) + elif status_code == 429: + retry_after = response.headers.get("Retry-After") + raise RateLimitError( + "Rate limit exceeded", + retry_after=int(retry_after) if retry_after else None, + ) + elif status_code == 400: + raise ValidationError( + data.get("error", "Validation error"), + status_code=400, + response_data=data, + ) + elif status_code == 404: + if "/contacts/" in endpoint: + contact_id = endpoint.split("/contacts/")[-1].split("/")[0] + raise ContactNotFoundError(contact_id) + elif "/reminders/" in endpoint: + reminder_id = endpoint.split("/reminders/")[-1].split("/")[0] + raise ReminderNotFoundError(reminder_id) + elif "/timeline_items/" in endpoint: + note_id = endpoint.split("/timeline_items/")[-1].split("/")[0] + raise NoteNotFoundError(note_id) + raise DexAPIError("Not found", status_code=404, response_data=data) + else: + raise DexAPIError( + data.get("error", f"API error: {status_code}"), + status_code=status_code, + response_data=data, + ) diff --git a/src/dex_python/deduplication.py b/src/dex_python/deduplication.py index 083ed2e..25e2335 100644 --- a/src/dex_python/deduplication.py +++ b/src/dex_python/deduplication.py @@ -368,3 +368,31 @@ def score_row(row: tuple[Any, ...]) -> int: ) conn.commit() return primary_id + + +def find_all_duplicates( + conn: sqlite3.Connection, fuzzy_threshold: float = 0.98 +) -> tuple[list[dict[str, Any]], list[list[str]]]: + """Find and cluster all potential duplicates using multiple detection methods. + + This is a convenience function that runs all duplicate detection methods + and clusters the results into groups. + + Args: + conn: SQLite database connection. + fuzzy_threshold: Minimum similarity score for fuzzy name + matching (default: 0.98). + + Returns: + A tuple of (matches, clusters) where: + - matches: List of all duplicate signals found + - clusters: List of clustered contact ID groups + """ + matches = [] + matches.extend(find_email_duplicates(conn)) + matches.extend(find_phone_duplicates(conn)) + matches.extend(find_name_and_title_duplicates(conn)) + matches.extend(find_fuzzy_name_duplicates(conn, threshold=fuzzy_threshold)) + + clusters = cluster_duplicates(matches) + return matches, clusters diff --git a/tests/unit/deduplication/test_find_all_duplicates.py b/tests/unit/deduplication/test_find_all_duplicates.py new file mode 100644 index 0000000..f9605ed --- /dev/null +++ b/tests/unit/deduplication/test_find_all_duplicates.py @@ -0,0 +1,182 @@ +"""Tests for find_all_duplicates convenience function.""" + +import sqlite3 + +from dex_python.deduplication import find_all_duplicates + + +def setup_test_db() -> sqlite3.Connection: + """Create an in-memory test database with contacts.""" + conn = sqlite3.connect(":memory:") + cursor = conn.cursor() + + # Create schema + cursor.execute(""" + CREATE TABLE contacts ( + id TEXT PRIMARY KEY, + first_name TEXT, + last_name TEXT, + job_title TEXT + ) + """) + cursor.execute(""" + CREATE TABLE emails ( + id INTEGER PRIMARY KEY, + contact_id TEXT, + email TEXT + ) + """) + cursor.execute(""" + CREATE TABLE phones ( + id INTEGER PRIMARY KEY, + contact_id TEXT, + phone_number TEXT + ) + """) + + conn.commit() + return conn + + +def test_find_all_duplicates_basic() -> None: + """Test find_all_duplicates with email duplicates.""" + conn = setup_test_db() + cursor = conn.cursor() + + # Insert contacts with duplicate emails + cursor.execute( + "INSERT INTO contacts (id, first_name, last_name) VALUES (?, ?, ?)", + ("1", "John", "Doe"), + ) + cursor.execute( + "INSERT INTO contacts (id, first_name, last_name) VALUES (?, ?, ?)", + ("2", "Jane", "Doe"), + ) + + cursor.execute( + "INSERT INTO emails (contact_id, email) VALUES (?, ?)", + ("1", "john@example.com"), + ) + cursor.execute( + "INSERT INTO emails (contact_id, email) VALUES (?, ?)", + ("2", "john@example.com"), + ) + + conn.commit() + + matches, clusters = find_all_duplicates(conn) + + assert len(matches) > 0 + assert len(clusters) == 1 + assert set(clusters[0]) == {"1", "2"} + + conn.close() + + +def test_find_all_duplicates_no_duplicates() -> None: + """Test find_all_duplicates with no duplicates.""" + conn = setup_test_db() + cursor = conn.cursor() + + # Insert contacts with unique data + cursor.execute( + "INSERT INTO contacts (id, first_name, last_name) VALUES (?, ?, ?)", + ("1", "John", "Doe"), + ) + cursor.execute( + "INSERT INTO contacts (id, first_name, last_name) VALUES (?, ?, ?)", + ("2", "Jane", "Smith"), + ) + + cursor.execute( + "INSERT INTO emails (contact_id, email) VALUES (?, ?)", + ("1", "john@example.com"), + ) + cursor.execute( + "INSERT INTO emails (contact_id, email) VALUES (?, ?)", + ("2", "jane@example.com"), + ) + + conn.commit() + + matches, clusters = find_all_duplicates(conn) + + assert len(matches) == 0 + assert len(clusters) == 0 + + conn.close() + + +def test_find_all_duplicates_multiple_signals() -> None: + """Test find_all_duplicates with multiple duplicate signals.""" + conn = setup_test_db() + cursor = conn.cursor() + + # Insert contacts with both email and phone duplicates + cursor.execute( + "INSERT INTO contacts (id, first_name, last_name) VALUES (?, ?, ?)", + ("1", "John", "Doe"), + ) + cursor.execute( + "INSERT INTO contacts (id, first_name, last_name) VALUES (?, ?, ?)", + ("2", "John", "Doe"), + ) + + cursor.execute( + "INSERT INTO emails (contact_id, email) VALUES (?, ?)", + ("1", "john@example.com"), + ) + cursor.execute( + "INSERT INTO emails (contact_id, email) VALUES (?, ?)", + ("2", "john@example.com"), + ) + + cursor.execute( + "INSERT INTO phones (contact_id, phone_number) VALUES (?, ?)", + ("1", "1234567890"), + ) + cursor.execute( + "INSERT INTO phones (contact_id, phone_number) VALUES (?, ?)", + ("2", "1234567890"), + ) + + conn.commit() + + matches, clusters = find_all_duplicates(conn) + + # Should find multiple signals (email and phone) + assert len(matches) >= 2 + # But only one cluster since they're the same two contacts + assert len(clusters) == 1 + assert set(clusters[0]) == {"1", "2"} + + conn.close() + + +def test_find_all_duplicates_custom_threshold() -> None: + """Test find_all_duplicates with custom fuzzy threshold.""" + conn = setup_test_db() + cursor = conn.cursor() + + # Insert contacts with similar names + cursor.execute( + "INSERT INTO contacts (id, first_name, last_name) VALUES (?, ?, ?)", + ("1", "John", "Smith"), + ) + cursor.execute( + "INSERT INTO contacts (id, first_name, last_name) VALUES (?, ?, ?)", + ("2", "Jon", "Smith"), + ) + + conn.commit() + + # With high threshold, should not match + matches_high, clusters_high = find_all_duplicates(conn, fuzzy_threshold=0.99) + assert len(clusters_high) == 0 + + # With lower threshold, might match (depends on similarity score) + matches_low, clusters_low = find_all_duplicates(conn, fuzzy_threshold=0.85) + # This test is more about verifying the threshold parameter works + # The actual result depends on the Jaro-Winkler similarity + + conn.close() diff --git a/tests/unit/test_client_utils.py b/tests/unit/test_client_utils.py new file mode 100644 index 0000000..741324d --- /dev/null +++ b/tests/unit/test_client_utils.py @@ -0,0 +1,121 @@ +"""Tests for client_utils module.""" + +import pytest +from httpx import Response + +from dex_python.client_utils import handle_error, should_retry +from dex_python.exceptions import ( + AuthenticationError, + ContactNotFoundError, + DexAPIError, + NoteNotFoundError, + RateLimitError, + ReminderNotFoundError, + ValidationError, +) + + +class TestShouldRetry: + """Test should_retry function.""" + + def test_retries_429(self) -> None: + """Should retry rate limit errors.""" + assert should_retry(429) is True + + def test_retries_500(self) -> None: + """Should retry server errors.""" + assert should_retry(500) is True + + def test_retries_502(self) -> None: + """Should retry bad gateway.""" + assert should_retry(502) is True + + def test_retries_503(self) -> None: + """Should retry service unavailable.""" + assert should_retry(503) is True + + def test_retries_504(self) -> None: + """Should retry gateway timeout.""" + assert should_retry(504) is True + + def test_no_retry_400(self) -> None: + """Should not retry client errors.""" + assert should_retry(400) is False + + def test_no_retry_401(self) -> None: + """Should not retry auth errors.""" + assert should_retry(401) is False + + def test_no_retry_404(self) -> None: + """Should not retry not found.""" + assert should_retry(404) is False + + +class TestHandleError: + """Test handle_error function.""" + + def test_401_raises_authentication_error(self) -> None: + """Should raise AuthenticationError for 401.""" + response = Response(401, json={"error": "Unauthorized"}) + with pytest.raises(AuthenticationError, match="Invalid API key"): + handle_error(response, "/contacts") + + def test_429_raises_rate_limit_error(self) -> None: + """Should raise RateLimitError for 429.""" + response = Response(429, json={"error": "Too many requests"}) + with pytest.raises(RateLimitError, match="Rate limit exceeded"): + handle_error(response, "/contacts") + + def test_429_includes_retry_after(self) -> None: + """Should include retry_after from header.""" + response = Response( + 429, headers={"Retry-After": "60"}, json={"error": "Too many requests"} + ) + with pytest.raises(RateLimitError) as exc_info: + handle_error(response, "/contacts") + assert exc_info.value.retry_after == 60 + + def test_400_raises_validation_error(self) -> None: + """Should raise ValidationError for 400.""" + response = Response(400, json={"error": "Invalid input"}) + with pytest.raises(ValidationError, match="Invalid input"): + handle_error(response, "/contacts") + + def test_404_contacts_raises_contact_not_found(self) -> None: + """Should raise ContactNotFoundError for 404 on /contacts.""" + response = Response(404, json={"error": "Not found"}) + with pytest.raises(ContactNotFoundError) as exc_info: + handle_error(response, "/contacts/abc123") + assert exc_info.value.contact_id == "abc123" + + def test_404_reminders_raises_reminder_not_found(self) -> None: + """Should raise ReminderNotFoundError for 404 on /reminders.""" + response = Response(404, json={"error": "Not found"}) + with pytest.raises(ReminderNotFoundError) as exc_info: + handle_error(response, "/reminders/xyz789") + assert exc_info.value.reminder_id == "xyz789" + + def test_404_notes_raises_note_not_found(self) -> None: + """Should raise NoteNotFoundError for 404 on /timeline_items.""" + response = Response(404, json={"error": "Not found"}) + with pytest.raises(NoteNotFoundError) as exc_info: + handle_error(response, "/timeline_items/note123") + assert exc_info.value.note_id == "note123" + + def test_404_other_raises_dex_api_error(self) -> None: + """Should raise DexAPIError for 404 on other endpoints.""" + response = Response(404, json={"error": "Not found"}) + with pytest.raises(DexAPIError, match="Not found"): + handle_error(response, "/other") + + def test_500_raises_dex_api_error(self) -> None: + """Should raise DexAPIError for 500.""" + response = Response(500, json={"error": "Server error"}) + with pytest.raises(DexAPIError, match="Server error"): + handle_error(response, "/contacts") + + def test_handles_non_json_response(self) -> None: + """Should handle responses without JSON.""" + response = Response(500, text="Server error") + with pytest.raises(DexAPIError): + handle_error(response, "/contacts")