Coverage for src / ai_lls_lib / providers / external.py: 93%
46 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-06 23:45 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-06 23:45 +0000
1"""
2External API provider for production phone verification
3"""
5import os
7import httpx
8from aws_lambda_powertools import Logger
10from ..core.models import LineType
11from .exceptions import ProviderError
13logger = Logger()
16class ExternalAPIProvider:
17 """
18 Production provider that calls external verification APIs.
19 Uses landlineremover.com which returns both line type and DNC status in a single call.
20 """
22 def __init__(self, timeout: float = 10.0):
23 """
24 Initialize external API provider.
26 Args:
27 timeout: HTTP request timeout in seconds
28 """
29 self.api_key = os.environ.get("LANDLINE_REMOVER_API_KEY", "")
30 if not self.api_key:
31 logger.warning("LANDLINE_REMOVER_API_KEY not set")
33 self.api_url = "https://app.landlineremover.com/api/check-number"
34 self.timeout = timeout
35 self.http_client = httpx.Client(timeout=timeout)
37 def verify_phone(self, phone: str) -> tuple[LineType, bool]:
38 """
39 Verify phone using landlineremover.com API.
41 This API returns both line type and DNC status in a single call,
42 which is more efficient than making two separate API calls.
44 Args:
45 phone: E.164 formatted phone number
47 Returns:
48 Tuple of (line_type, is_on_dnc_list)
50 Raises:
51 httpx.HTTPError: For API communication errors
52 ValueError: For invalid responses
53 """
54 logger.debug(f"Verifying phone {phone[:6]}*** via external API")
56 if not self.api_key:
57 raise ValueError("API key not configured")
59 try:
60 # Make single API call that returns both line type and DNC status
61 # The API may redirect, so we need to follow redirects
62 response = self.http_client.get(
63 self.api_url,
64 params={"apikey": self.api_key, "number": phone},
65 follow_redirects=True,
66 )
68 # Raise for HTTP errors
69 response.raise_for_status()
71 # Parse response
72 json_response = response.json()
74 # Extract data from response wrapper
75 if "data" in json_response:
76 data = json_response["data"]
77 else:
78 data = json_response
80 # Map line type from API response
81 line_type = self._map_line_type(data)
83 # Map DNC status - API uses "DNCType" field
84 # Values can be "dnc", "clean", etc.
85 dnc_type = data.get("DNCType", data.get("dnc_type", "")).lower()
86 is_dnc = dnc_type != "clean" and dnc_type != ""
88 logger.debug(
89 f"Verification complete for {phone[:6]}***",
90 extra={"line_type": line_type.value, "is_dnc": is_dnc, "dnc_type": dnc_type},
91 )
93 return line_type, is_dnc
95 except httpx.HTTPStatusError as e:
96 logger.error(f"API error: {e.response.status_code} - {e.response.text}")
97 raise ProviderError(
98 f"API request failed with status {e.response.status_code}",
99 status_code=e.response.status_code,
100 ) from e
101 except httpx.RequestError as e:
102 logger.error(f"Network error: {str(e)}")
103 raise ProviderError(f"Network error during API call: {str(e)}") from e
104 except Exception as e:
105 logger.error(f"Unexpected error during verification: {str(e)}")
106 raise
108 def _map_line_type(self, data: dict) -> LineType:
109 """
110 Map API response to LineType enum.
112 Args:
113 data: API response dictionary
115 Returns:
116 LineType enum value
117 """
118 # API uses "LineType" (capitalized) field
119 line_type_str = data.get("LineType", data.get("line_type", "")).lower()
121 # Map common line types
122 line_type_map = {
123 "mobile": LineType.MOBILE,
124 "landline": LineType.LANDLINE,
125 "voip": LineType.VOIP,
126 "wireless": LineType.MOBILE, # Some APIs return "wireless" for mobile
127 "fixed": LineType.LANDLINE, # Some APIs return "fixed" for landline
128 }
130 return line_type_map.get(line_type_str, LineType.UNKNOWN)
132 def __del__(self) -> None:
133 """Cleanup HTTP client"""
134 if hasattr(self, "http_client"):
135 self.http_client.close()