Skip to content

Commit ca53a7e

Browse files
Wrap responses in a new type to include credits and throttling data
This fixes #44.
1 parent d1dab9b commit ca53a7e

File tree

4 files changed

+228
-37
lines changed

4 files changed

+228
-37
lines changed

ipregistry/core.py

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"""
1616

1717
from .cache import IpregistryCache, NoCache
18-
from .model import LookupError
18+
from .model import LookupError, ApiResponse, ApiResponseCredits, ApiResponseThrottling
1919
from .request import DefaultRequestHandler, IpregistryRequestHandler
2020

2121

@@ -30,7 +30,7 @@ def __init__(self, key_or_config, **kwargs):
3030
if not isinstance(self._requestHandler, IpregistryRequestHandler):
3131
raise ValueError("Given request handler instance is not of type IpregistryRequestHandler")
3232

33-
def batch_lookup_ips(self, ips, options):
33+
def batch_lookup_ips(self, ips, **options):
3434
sparse_cache = [None] * len(ips)
3535
cache_misses = []
3636

@@ -44,21 +44,32 @@ def batch_lookup_ips(self, ips, options):
4444
sparse_cache[i] = cache_value
4545

4646
result = [None] * len(ips)
47-
fresh_ip_info = self._requestHandler.batch_lookup_ips(cache_misses, options)
47+
if len(cache_misses) > 0:
48+
response = self._requestHandler.batch_lookup_ips(cache_misses, options)
49+
else:
50+
response = ApiResponse(
51+
ApiResponseCredits(),
52+
[],
53+
ApiResponseThrottling()
54+
)
55+
56+
fresh_ip_info = response.data
4857
j = 0
4958
k = 0
5059

51-
for cachedIpInfo in sparse_cache:
52-
if cachedIpInfo is None:
60+
for cached_ip_info in sparse_cache:
61+
if cached_ip_info is None:
5362
if not isinstance(fresh_ip_info[k], LookupError):
5463
self._cache.put(self.__build_cache_key(ips[j], options), fresh_ip_info[k])
5564
result[j] = fresh_ip_info[k]
5665
k += 1
5766
else:
58-
result[j] = cachedIpInfo
67+
result[j] = cached_ip_info
5968
j += 1
6069

61-
return result
70+
response.data = result
71+
72+
return response
6273

6374
def lookup_ip(self, ip='', **options):
6475
if isinstance(ip, str) and len(ip) > 0:
@@ -74,10 +85,15 @@ def __lookup_ip(self, ip, options):
7485
cache_value = self._cache.get(cache_key)
7586

7687
if cache_value is None:
77-
cache_value = self._requestHandler.lookup_ip(ip, options)
78-
self._cache.put(cache_key, cache_value)
79-
80-
return cache_value
88+
response = self._requestHandler.lookup_ip(ip, options)
89+
self._cache.put(cache_key, response.data)
90+
return response
91+
92+
return ApiResponse(
93+
ApiResponseCredits(),
94+
cache_value,
95+
ApiResponseThrottling()
96+
)
8197

8298
@staticmethod
8399
def __build_cache_key(ip, options):

ipregistry/model.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,41 @@
1515
"""
1616

1717
from .json import *
18+
from typing import Generic, TypeVar, Optional, Dict, Any
19+
20+
T = TypeVar('T')
21+
22+
23+
class ApiResponseCredits:
24+
def __init__(self, consumed: Optional[int] = 0, remaining: Optional[int] = None):
25+
self.consumed = consumed
26+
self.remaining = remaining
27+
28+
def __str__(self):
29+
fields = ', '.join(f"{key}={value}" for key, value in self.__dict__.items())
30+
return f"{self.__class__.__name__}({fields})"
31+
32+
33+
class ApiResponseThrottling:
34+
def __init__(self, limit: int = None, remaining: int = None, reset: int = None):
35+
self.limit = limit
36+
self.remaining = remaining
37+
self.reset = reset
38+
39+
def __str__(self):
40+
fields = ', '.join(f"{key}={value}" for key, value in self.__dict__.items())
41+
return f"{self.__class__.__name__}({fields})"
42+
43+
44+
class ApiResponse(Generic[T]):
45+
def __init__(self, credits: ApiResponseCredits, data: T, throttling: Optional[ApiResponseThrottling] = None):
46+
self.credits = credits
47+
self.data = data
48+
self.throttling = throttling
49+
50+
def __str__(self):
51+
fields = ', '.join(f"{key}={value}" for key, value in self.__dict__.items())
52+
return f"{self.__class__.__name__}({fields})"
1853

1954

2055
class RequesterIpInfo(IpInfo):
@@ -26,12 +61,20 @@ class IpregistryError(Exception):
2661

2762

2863
class ApiError(IpregistryError):
29-
pass
64+
def __init__(self, code: str, message: str, resolution: str):
65+
self.code = code
66+
self.message = message
67+
self.resolution = resolution
3068

3169

3270
class ClientError(IpregistryError):
3371
pass
3472

3573

36-
class LookupError(IpregistryError):
37-
pass
74+
class LookupError(ApiError):
75+
def __init__(self, json: Dict[str, Any]):
76+
super().__init__(
77+
code=json.get('code'),
78+
message=json.get('message'),
79+
resolution=json.get('resolution')
80+
)

ipregistry/request.py

Lines changed: 73 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@
1414
limitations under the License.
1515
"""
1616

17-
from abc import ABC, abstractmethod
1817
import json
19-
import requests
2018
import sys
2119
import urllib.parse
20+
from abc import ABC, abstractmethod
21+
from typing import Union
22+
23+
import requests
2224

2325
from .__init__ import __version__
24-
from .model import ApiError, ClientError, IpInfo
26+
from .model import ApiError, ApiResponse, ApiResponseCredits, ApiResponseThrottling, ClientError, IpInfo, LookupError
2527

2628

2729
class IpregistryRequestHandler(ABC):
@@ -53,28 +55,86 @@ def _build_base_url(self, ip, options):
5355

5456
class DefaultRequestHandler(IpregistryRequestHandler):
5557
def batch_lookup_ips(self, ips, options):
58+
response = None
5659
try:
57-
r = requests.post(self._build_base_url('', options), data=json.dumps(ips), headers=self.__headers(), timeout=self._config.timeout)
58-
r.raise_for_status()
59-
return list(map(lambda data: LookupError(data) if 'code' in data else IpInfo(**data), r.json()['results']))
60+
response = requests.post(
61+
self._build_base_url('', options),
62+
data=json.dumps(ips),
63+
headers=self.__headers(),
64+
timeout=self._config.timeout
65+
)
66+
response.raise_for_status()
67+
results = response.json().get('results', [])
68+
69+
parsed_results = [
70+
LookupError(data) if 'code' in data else IpInfo(**data)
71+
for data in results
72+
]
73+
74+
return self.build_api_response(response, parsed_results)
6075
except requests.HTTPError:
61-
raise ApiError(r.json())
76+
self.__create_api_error(response)
6277
except Exception as e:
6378
raise ClientError(e)
6479

6580
def lookup_ip(self, ip, options):
81+
response = None
6682
try:
67-
r = requests.get(self._build_base_url(ip, options), headers=self.__headers(), timeout=self._config.timeout)
68-
r.raise_for_status()
69-
return IpInfo(**r.json())
83+
response = requests.get(
84+
self._build_base_url(ip, options),
85+
headers=self.__headers(),
86+
timeout=self._config.timeout
87+
)
88+
response.raise_for_status()
89+
json_response = response.json()
90+
91+
return self.build_api_response(response, IpInfo(**json_response))
7092
except requests.HTTPError:
71-
raise ApiError(r.json())
72-
except Exception as e:
73-
raise ClientError(e)
93+
self.__create_api_error(response)
94+
except Exception as err:
95+
raise ClientError(err)
7496

7597
def origin_lookup_ip(self, options):
7698
return self.lookup_ip('', options)
7799

100+
@staticmethod
101+
def build_api_response(response, data):
102+
throttling_limit = DefaultRequestHandler.__convert_to_int(response.headers.get('x-rate-limit-limit'))
103+
throttling_remaining = DefaultRequestHandler.__convert_to_int(response.headers.get('x-rate-limit-remaining'))
104+
throttling_reset = DefaultRequestHandler.__convert_to_int(response.headers.get('x-rate-limit-reset'))
105+
106+
ipregistry_credits_consumed = DefaultRequestHandler.__convert_to_int(response.headers.get('ipregistry-credits-consumed'))
107+
ipregistry_credits_remaining = DefaultRequestHandler.__convert_to_int(response.headers.get('ipregistry-credits-remaining'))
108+
109+
return ApiResponse(
110+
ApiResponseCredits(
111+
ipregistry_credits_consumed,
112+
ipregistry_credits_remaining,
113+
),
114+
data,
115+
None if throttling_limit is None and throttling_remaining is None and throttling_reset is None else
116+
ApiResponseThrottling(
117+
throttling_limit,
118+
throttling_remaining,
119+
throttling_reset
120+
)
121+
)
122+
123+
@staticmethod
124+
def __convert_to_int(value: str) -> Union[int, None]:
125+
try:
126+
return int(value)
127+
except (ValueError, TypeError):
128+
return None
129+
130+
@staticmethod
131+
def __create_api_error(response):
132+
if response is not None:
133+
json_response = response.json()
134+
raise ApiError(json_response['code'], json_response['message'], json_response['resolution'])
135+
else:
136+
raise ClientError("HTTP Error occurred, but no response was received.")
137+
78138
@staticmethod
79139
def __headers():
80140
return {

tests/test_client.py

Lines changed: 82 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,37 +17,109 @@
1717
import os
1818
import unittest
1919

20+
from ipregistry import ApiError, IpInfo, LookupError, ClientError
2021
from ipregistry.cache import InMemoryCache, NoCache
21-
from ipregistry.core import IpregistryClient
22+
from ipregistry.core import IpregistryClient, IpregistryConfig
2223

2324

2425
class TestIpregistryClient(unittest.TestCase):
25-
def test_defaultclient_cache(self):
26+
27+
def test_batch_lookup_ips(self):
28+
"""
29+
Test batch ips lookup with valid and invalid inputs
30+
"""
31+
client = IpregistryClient(os.getenv('IPREGISTRY_API_KEY'))
32+
response = client.batch_lookup_ips(['1.1.1.1', 'invalid', '8.8.8.8'])
33+
self.assertEqual(3, len(response.data))
34+
self.assertEqual(True, isinstance(response.data[0], IpInfo))
35+
self.assertEqual(True, isinstance(response.data[1], LookupError))
36+
self.assertEqual('INVALID_IP_ADDRESS', response.data[1].code)
37+
self.assertEqual(True, isinstance(response.data[2], IpInfo))
38+
39+
def test_client_cache_default(self):
2640
"""
2741
Test that default cache is an instance of NoCache
2842
"""
29-
client = IpregistryClient("")
43+
client = IpregistryClient("tryout")
3044
self.assertEqual(True, isinstance(client._cache, NoCache))
3145

32-
def test_simple_lookup(self):
46+
def test_client_cache_inmemory_ip_lookup(self):
47+
"""
48+
Test the client in memory cache
49+
"""
50+
client = IpregistryClient(os.getenv('IPREGISTRY_API_KEY'), cache=InMemoryCache(maxsize=2048, ttl=600))
51+
lookup_ip_response = client.lookup_ip('1.1.1.3')
52+
lookup_ip_response2 = client.lookup_ip('1.1.1.3')
53+
54+
self.assertEqual(1, lookup_ip_response.credits.consumed)
55+
self.assertEqual(0, lookup_ip_response2.credits.consumed)
56+
57+
def test_client_cache_inmemory_batch_ips_lookup(self):
58+
"""
59+
Test the client in memory cache
60+
"""
61+
client = IpregistryClient(os.getenv('IPREGISTRY_API_KEY'), cache=InMemoryCache(maxsize=2048, ttl=600))
62+
lookup_ip_response = client.lookup_ip('1.1.1.3')
63+
batch_ips_response = client.batch_lookup_ips(['1.1.1.1', '1.1.1.3'])
64+
65+
self.assertEqual(1, lookup_ip_response.credits.consumed)
66+
self.assertEqual(1, batch_ips_response.credits.consumed)
67+
68+
batch_ips_response2 = client.batch_lookup_ips(['1.1.1.1', '1.1.1.3'])
69+
self.assertEqual(0, batch_ips_response2.credits.consumed)
70+
71+
def test_lookup_ip(self):
3372
"""
3473
Test that a simple lookup returns data
3574
"""
3675
client = IpregistryClient(os.getenv('IPREGISTRY_API_KEY'))
3776
response = client.lookup_ip('8.8.8.8')
38-
self.assertIsNotNone(response.ip)
39-
self.assertIsNotNone(response.company.domain)
40-
self.assertEqual('US', response.location.country.code)
77+
self.assertIsNotNone(response.data.ip)
78+
self.assertIsNotNone(response.data.company.domain)
79+
self.assertEqual('US', response.data.location.country.code)
4180

42-
def test_simple_lookup_in_memory_cache(self):
81+
def test_lookup_ip_invalid_input(self):
82+
"""
83+
Test that an IP lookup with an invalid input fails with an ApiError
84+
"""
85+
client = IpregistryClient(os.getenv('IPREGISTRY_API_KEY'))
86+
with self.assertRaises(ApiError) as context:
87+
response = client.lookup_ip('invalid')
88+
print("test", context.exception)
89+
self.assertEqual('INVALID_IP_ADDRESS', context.exception.code)
90+
91+
def test_lookup_ip_cache(self):
4392
"""
4493
Test consecutive lookup with in-memory cache
4594
"""
4695
client = IpregistryClient(os.getenv('IPREGISTRY_API_KEY'), cache=InMemoryCache(maxsize=2048, ttl=600))
4796
response = client.lookup_ip('8.8.8.8')
4897
response = client.lookup_ip('8.8.8.8')
49-
self.assertIsNotNone(response.ip)
50-
self.assertIsNotNone(response.company.domain)
98+
self.assertIsNotNone(response.data.ip)
99+
self.assertIsNotNone(response.data.company.domain)
100+
101+
def test_response_metadata(self):
102+
"""
103+
Test metadata returned for each successful response
104+
"""
105+
client = IpregistryClient(os.getenv('IPREGISTRY_API_KEY'))
106+
batch_ips_lookup_response = client.batch_lookup_ips(['1.1.1.1', 'invalid', '8.8.8.8'])
107+
lookup_ip_response = client.lookup_ip('1.1.1.2')
108+
109+
self.assertEqual(3, batch_ips_lookup_response.credits.consumed)
110+
self.assertEqual(1, lookup_ip_response.credits.consumed)
111+
112+
self.assertIsNotNone(batch_ips_lookup_response.credits.remaining)
113+
self.assertIsNotNone(lookup_ip_response.credits.remaining)
114+
115+
def test_lookup_timeout(self):
116+
"""
117+
Test a client error is raised upon connection timeout
118+
"""
119+
client = IpregistryClient(IpregistryConfig(os.getenv('IPREGISTRY_API_KEY'), "https://api.ipregistry.co",
120+
0.0001))
121+
with self.assertRaises(ClientError):
122+
client.lookup_ip('1.1.1.1')
51123

52124

53125
if __name__ == '__main__':

0 commit comments

Comments
 (0)