Skip to content

Commit b647b18

Browse files
Fix batch lookups with a fields selection and fail cleanly on short batch responses
1 parent 7819c42 commit b647b18

6 files changed

Lines changed: 121 additions & 8 deletions

File tree

‎ipregistry/async_client.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
ClientError, IpregistryLookupError, RequesterAutonomousSystem,
2525
RequesterIpInfo, RequesterUserAgent, UserAgent)
2626
from .request import (DefaultRequestHandler, IpregistryRequestHandler, _backoff_interval,
27-
_build_headers, _is_retryable_status, _raise_api_error, _retry_delay)
27+
_build_headers, _is_retryable_status, _prefix_batch_fields,
28+
_raise_api_error, _retry_delay)
2829

2930
try:
3031
import httpx
@@ -57,7 +58,7 @@ async def aclose(self):
5758
async def batch_lookup_asns(self, asns, options):
5859
response = await self._request_with_retry(
5960
'POST',
60-
self._build_base_url('', options),
61+
self._build_base_url('', _prefix_batch_fields(options)),
6162
data=json.dumps(["AS" + str(asn) for asn in asns])
6263
)
6364
try:
@@ -73,7 +74,7 @@ async def batch_lookup_asns(self, asns, options):
7374
async def batch_lookup_ips(self, ips, options):
7475
response = await self._request_with_retry(
7576
'POST',
76-
self._build_base_url('', options),
77+
self._build_base_url('', _prefix_batch_fields(options)),
7778
data=json.dumps(ips)
7879
)
7980
try:
@@ -89,7 +90,7 @@ async def batch_lookup_ips(self, ips, options):
8990
async def batch_parse_user_agents(self, user_agents, options):
9091
response = await self._request_with_retry(
9192
'POST',
92-
self._build_base_url('user_agent', options),
93+
self._build_base_url('user_agent', _prefix_batch_fields(options)),
9394
data=json.dumps(user_agents)
9495
)
9596
try:
@@ -239,6 +240,10 @@ async def batch_request(self, items, request_handler_func, **options):
239240
)
240241

241242
fresh_item_info = response.data
243+
if len(fresh_item_info) != len(cache_misses):
244+
raise ClientError(
245+
"Batch response contained {} results for {} requested items.".format(
246+
len(fresh_item_info), len(cache_misses)))
242247
j = 0
243248
k = 0
244249

‎ipregistry/core.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717

1818
from .cache import IpregistryCache, NoCache
1919
from .json import AutonomousSystem, IpInfo
20-
from .model import IpregistryLookupError, ApiResponse, ApiResponseCredits, ApiResponseThrottling
20+
from .model import (ApiResponse, ApiResponseCredits, ApiResponseThrottling, ClientError,
21+
IpregistryLookupError)
2122
from .request import DefaultRequestHandler, IpregistryRequestHandler
2223

2324
MAX_BATCH_SIZE = 1024
@@ -124,6 +125,10 @@ def batch_request(self, items, request_handler_func, **options):
124125
)
125126

126127
fresh_item_info = response.data
128+
if len(fresh_item_info) != len(cache_misses):
129+
raise ClientError(
130+
"Batch response contained {} results for {} requested items.".format(
131+
len(fresh_item_info), len(cache_misses)))
127132
j = 0
128133
k = 0
129134

‎ipregistry/request.py‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,25 @@ def _raise_api_error(response):
7171
raise ApiError(code, message, resolution)
7272

7373

74+
def _prefix_batch_fields(options):
75+
"""Return options with the fields selection adjusted for batch endpoints.
76+
77+
Batch responses wrap entries in a 'results' array and the API applies the
78+
fields filter to the whole response body, so each field must be prefixed
79+
with 'results.' to select fields of the individual entries."""
80+
fields = options.get('fields')
81+
if not fields:
82+
return options
83+
84+
result = dict(options)
85+
result['fields'] = ','.join(
86+
field if field.startswith('results.') else 'results.' + field
87+
for field in (part.strip() for part in str(fields).split(','))
88+
if field
89+
)
90+
return result
91+
92+
7493
def _is_retryable_status(config, status_code):
7594
if status_code == 429:
7695
return config.retry_on_too_many_requests
@@ -155,7 +174,7 @@ def close(self):
155174
def batch_lookup_asns(self, asns, options):
156175
response = self._request_with_retry(
157176
'POST',
158-
self._build_base_url('', options),
177+
self._build_base_url('', _prefix_batch_fields(options)),
159178
data=json.dumps(["AS" + str(asn) for asn in asns])
160179
)
161180
try:
@@ -171,7 +190,7 @@ def batch_lookup_asns(self, asns, options):
171190
def batch_lookup_ips(self, ips, options):
172191
response = self._request_with_retry(
173192
'POST',
174-
self._build_base_url('', options),
193+
self._build_base_url('', _prefix_batch_fields(options)),
175194
data=json.dumps(ips)
176195
)
177196
try:
@@ -187,7 +206,7 @@ def batch_lookup_ips(self, ips, options):
187206
def batch_parse_user_agents(self, user_agents, options):
188207
response = self._request_with_retry(
189208
'POST',
190-
self._build_base_url('user_agent', options),
209+
self._build_base_url('user_agent', _prefix_batch_fields(options)),
191210
data=json.dumps(user_agents)
192211
)
193212
try:

‎tests/test_async_client.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,35 @@ def responder(request):
169169
self.assertEqual('1.1.1.1', response.data[0].ip)
170170
self.assertEqual('INVALID_IP_ADDRESS', response.data[1].code)
171171

172+
async def test_batch_lookup_fields_are_prefixed(self):
173+
"""
174+
Test that async batch lookups prefix the fields selection with 'results.'
175+
"""
176+
seen_queries = []
177+
178+
def responder(request):
179+
seen_queries.append(request.url.query.decode())
180+
return httpx.Response(200, json={'results': [{'ip': '8.8.8.8'}]})
181+
182+
async with build_client(responder) as client:
183+
await client.batch_lookup_ips(['8.8.8.8'], fields='ip,location')
184+
self.assertIn('results.ip', seen_queries[0])
185+
self.assertIn('results.location', seen_queries[0])
186+
self.assertNotIn('fields=ip', seen_queries[0])
187+
188+
async def test_batch_response_length_mismatch_raises_client_error(self):
189+
"""
190+
Test that a batch response with fewer results than requested items
191+
raises a ClientError instead of crashing with an IndexError
192+
"""
193+
def responder(request):
194+
return httpx.Response(200, json={})
195+
196+
async with build_client(responder) as client:
197+
with self.assertRaises(ClientError) as context:
198+
await client.batch_lookup_ips(['1.1.1.1', '8.8.8.8'])
199+
self.assertIn('0 results for 2', str(context.exception))
200+
172201
async def test_missing_httpx_raises_helpful_error(self):
173202
"""
174203
Test that constructing the handler without httpx raises a helpful error

‎tests/test_client.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,20 @@ def test_batch_lookup_no_split_within_limit(self):
114114
client.batch_lookup_ips(['1.1.1.1', '1.1.1.2'])
115115
self.assertEqual(1, handler.calls)
116116

117+
def test_batch_response_length_mismatch_raises_client_error(self):
118+
"""
119+
Test that a batch response with fewer results than requested items
120+
raises a ClientError instead of crashing with an IndexError
121+
"""
122+
class ShortResponseHandler(CountingRequestHandler):
123+
def batch_lookup_ips(self, ips, options):
124+
return ApiResponse(ApiResponseCredits(), [], ApiResponseThrottling())
125+
126+
client = IpregistryClient("tryout", requestHandler=ShortResponseHandler())
127+
with self.assertRaises(ClientError) as context:
128+
client.batch_lookup_ips(['1.1.1.1', '8.8.8.8'])
129+
self.assertIn('0 results for 2', str(context.exception))
130+
117131
def test_max_batch_size_capped(self):
118132
"""
119133
Test that max_batch_size cannot exceed the API limit of 1024
@@ -220,6 +234,16 @@ def test_batch_lookup_ips(self):
220234
self.assertEqual('INVALID_IP_ADDRESS', response.data[1].code)
221235
self.assertEqual(True, isinstance(response.data[2], IpInfo))
222236

237+
def test_batch_lookup_ips_with_fields(self):
238+
"""
239+
Test that batch lookups support field selection
240+
"""
241+
client = IpregistryClient(os.getenv('IPREGISTRY_API_KEY'))
242+
response = client.batch_lookup_ips(['8.8.8.8'], fields='ip,location')
243+
self.assertEqual('8.8.8.8', response.data[0].ip)
244+
self.assertEqual('US', response.data[0].location.country.code)
245+
self.assertEqual(None, response.data[0].security)
246+
223247
def test_batch_parse_user_agents(self):
224248
"""
225249
Test batch parse user agents

‎tests/test_request.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,37 @@ def test_build_base_url_option_value_types(self):
5050
url = handler._build_base_url('8.8.8.8', {'hostname': True, 'fields': 'location', 'n': 5})
5151
self.assertEqual('https://api.ipregistry.co/8.8.8.8?hostname=true&fields=location&n=5', url)
5252

53+
def test_batch_fields_are_prefixed(self):
54+
"""
55+
Test that batch lookups prefix the fields selection with 'results.'
56+
as the API applies the filter to the whole batch response body
57+
"""
58+
handler, adapter = build_mocked_handler()
59+
adapter.register_uri('POST', 'https://api.ipregistry.co/',
60+
json={'results': [{'ip': '8.8.8.8'}]})
61+
handler.batch_lookup_ips(['8.8.8.8'], {'fields': 'ip, location,results.security'})
62+
self.assertEqual(['results.ip,results.location,results.security'],
63+
adapter.last_request.qs['fields'])
64+
65+
def test_batch_parse_user_agents_fields_are_prefixed(self):
66+
"""
67+
Test that batch user-agent parsing prefixes the fields selection too
68+
"""
69+
handler, adapter = build_mocked_handler()
70+
adapter.register_uri('POST', 'https://api.ipregistry.co/user_agent',
71+
json={'results': [{'name': 'Chrome'}]})
72+
handler.batch_parse_user_agents(['Mozilla/5.0'], {'fields': 'name'})
73+
self.assertEqual(['results.name'], adapter.last_request.qs['fields'])
74+
75+
def test_single_lookup_fields_are_not_prefixed(self):
76+
"""
77+
Test that single lookups keep the fields selection unchanged
78+
"""
79+
handler, adapter = build_mocked_handler()
80+
adapter.register_uri('GET', 'https://api.ipregistry.co/8.8.8.8', json={'ip': '8.8.8.8'})
81+
handler.lookup_ip('8.8.8.8', {'fields': 'ip,location'})
82+
self.assertEqual(['ip,location'], adapter.last_request.qs['fields'])
83+
5384
def test_create_api_error_json_response(self):
5485
"""
5586
Test that a JSON error response raises an ApiError with its fields

0 commit comments

Comments
 (0)