Skip to content

Commit 6982b6e

Browse files
authored
Merge pull request #121 from ipinfo/silvano/eng-714-resproxy-support-in-api-with-batch-endpoint
Fix batch requests failing when using prefixed IPs
2 parents ce7c91b + a067ff5 commit 6982b6e

File tree

5 files changed

+312
-85
lines changed

5 files changed

+312
-85
lines changed

ipinfo/handler.py

Lines changed: 29 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,34 @@
22
Main API client handler for fetching data from the IPinfo service.
33
"""
44

5-
from ipaddress import IPv4Address, IPv6Address
65
import time
6+
from ipaddress import IPv4Address, IPv6Address
77

88
import requests
99

10-
from .error import APIError
10+
from . import handler_utils
11+
from .bogon import is_bogon
1112
from .cache.default import DefaultCache
13+
from .data import (
14+
continents,
15+
countries,
16+
countries_currencies,
17+
countries_flags,
18+
eu_countries,
19+
)
1220
from .details import Details
21+
from .error import APIError
1322
from .exceptions import RequestQuotaExceededError, TimeoutExceededError
1423
from .handler_utils import (
1524
API_URL,
16-
RESPROXY_API_URL,
1725
BATCH_MAX_SIZE,
26+
BATCH_REQ_TIMEOUT_DEFAULT,
1827
CACHE_MAXSIZE,
1928
CACHE_TTL,
2029
REQUEST_TIMEOUT_DEFAULT,
21-
BATCH_REQ_TIMEOUT_DEFAULT,
30+
RESPROXY_API_URL,
2231
cache_key,
23-
)
24-
from . import handler_utils
25-
from .bogon import is_bogon
26-
from .data import (
27-
continents,
28-
countries,
29-
countries_currencies,
30-
eu_countries,
31-
countries_flags,
32+
is_prefixed_lookup,
3233
)
3334

3435

@@ -91,9 +92,7 @@ def getDetails(self, ip_address=None, timeout=None):
9192
# If the supplied IP address uses the objects defined in the built-in
9293
# module ipaddress extract the appropriate string notation before
9394
# formatting the URL.
94-
if isinstance(ip_address, IPv4Address) or isinstance(
95-
ip_address, IPv6Address
96-
):
95+
if isinstance(ip_address, IPv4Address) or isinstance(ip_address, IPv6Address):
9796
ip_address = ip_address.exploded
9897

9998
# check if bogon.
@@ -125,11 +124,11 @@ def getDetails(self, ip_address=None, timeout=None):
125124
raise RequestQuotaExceededError()
126125
if response.status_code >= 400:
127126
error_code = response.status_code
128-
content_type = response.headers.get('Content-Type')
129-
if content_type == 'application/json':
127+
content_type = response.headers.get("Content-Type")
128+
if content_type == "application/json":
130129
error_response = response.json()
131130
else:
132-
error_response = {'error': response.text}
131+
error_response = {"error": response.text}
133132
raise APIError(error_code, error_response)
134133
details = response.json()
135134

@@ -196,7 +195,6 @@ def getResproxy(self, ip_address, timeout=None):
196195

197196
return Details(details)
198197

199-
200198
def getBatchDetails(
201199
self,
202200
ip_addresses,
@@ -251,7 +249,11 @@ def getBatchDetails(
251249
):
252250
ip_address = ip_address.exploded
253251

254-
if ip_address and is_bogon(ip_address):
252+
if (
253+
ip_address
254+
and not is_prefixed_lookup(ip_address)
255+
and is_bogon(ip_address)
256+
):
255257
details = {}
256258
details["ip"] = ip_address
257259
details["bogon"] = True
@@ -280,10 +282,7 @@ def getBatchDetails(
280282
headers["content-type"] = "application/json"
281283
for i in range(0, len(lookup_addresses), batch_size):
282284
# quit if total timeout is reached.
283-
if (
284-
timeout_total is not None
285-
and time.time() - start_time > timeout_total
286-
):
285+
if timeout_total is not None and time.time() - start_time > timeout_total:
287286
return handler_utils.return_or_fail(
288287
raise_on_fail, TimeoutExceededError(), result
289288
)
@@ -292,9 +291,7 @@ def getBatchDetails(
292291

293292
# lookup
294293
try:
295-
response = requests.post(
296-
url, json=chunk, headers=headers, **req_opts
297-
)
294+
response = requests.post(url, json=chunk, headers=headers, **req_opts)
298295
except Exception as e:
299296
return handler_utils.return_or_fail(raise_on_fail, e, result)
300297

@@ -347,9 +344,7 @@ def getMap(self, ips):
347344
url = f"{API_URL}/map?cli=1"
348345
headers = handler_utils.get_headers(None, self.headers)
349346
headers["content-type"] = "application/json"
350-
response = requests.post(
351-
url, json=ip_strs, headers=headers, **req_opts
352-
)
347+
response = requests.post(url, json=ip_strs, headers=headers, **req_opts)
353348
response.raise_for_status()
354349
return response.json()["reportUrl"]
355350

@@ -370,7 +365,9 @@ def getBatchDetailsIter(
370365
):
371366
ip_address = ip_address.exploded
372367

373-
if ip_address and is_bogon(ip_address):
368+
if is_prefixed_lookup(ip_address):
369+
lookup_addresses.append(ip_address)
370+
elif ip_address and is_bogon(ip_address):
374371
details = {}
375372
details["ip"] = ip_address
376373
details["bogon"] = True

ipinfo/handler_async.py

Lines changed: 47 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,36 @@
22
Main API client asynchronous handler for fetching data from the IPinfo service.
33
"""
44

5-
from ipaddress import IPv4Address, IPv6Address
65
import asyncio
76
import json
87
import time
8+
from ipaddress import IPv4Address, IPv6Address
99

1010
import aiohttp
1111

12-
from .error import APIError
12+
from . import handler_utils
13+
from .bogon import is_bogon
1314
from .cache.default import DefaultCache
15+
from .data import (
16+
continents,
17+
countries,
18+
countries_currencies,
19+
countries_flags,
20+
eu_countries,
21+
)
1422
from .details import Details
23+
from .error import APIError
1524
from .exceptions import RequestQuotaExceededError, TimeoutExceededError
1625
from .handler_utils import (
1726
API_URL,
18-
RESPROXY_API_URL,
1927
BATCH_MAX_SIZE,
28+
BATCH_REQ_TIMEOUT_DEFAULT,
2029
CACHE_MAXSIZE,
2130
CACHE_TTL,
2231
REQUEST_TIMEOUT_DEFAULT,
23-
BATCH_REQ_TIMEOUT_DEFAULT,
32+
RESPROXY_API_URL,
2433
cache_key,
25-
)
26-
from . import handler_utils
27-
from .bogon import is_bogon
28-
from .data import (
29-
continents,
30-
countries,
31-
countries_currencies,
32-
eu_countries,
33-
countries_flags,
34+
is_prefixed_lookup,
3435
)
3536

3637

@@ -117,9 +118,7 @@ async def getDetails(self, ip_address=None, timeout=None):
117118
# If the supplied IP address uses the objects defined in the built-in
118119
# module ipaddress, extract the appropriate string notation before
119120
# formatting the URL.
120-
if isinstance(ip_address, IPv4Address) or isinstance(
121-
ip_address, IPv6Address
122-
):
121+
if isinstance(ip_address, IPv4Address) or isinstance(ip_address, IPv6Address):
123122
ip_address = ip_address.exploded
124123

125124
# check if bogon.
@@ -147,11 +146,11 @@ async def getDetails(self, ip_address=None, timeout=None):
147146
raise RequestQuotaExceededError()
148147
if resp.status >= 400:
149148
error_code = resp.status
150-
content_type = resp.headers.get('Content-Type')
151-
if content_type == 'application/json':
149+
content_type = resp.headers.get("Content-Type")
150+
if content_type == "application/json":
152151
error_response = await resp.json()
153152
else:
154-
error_response = {'error': resp.text()}
153+
error_response = {"error": resp.text()}
155154
raise APIError(error_code, error_response)
156155
details = await resp.json()
157156

@@ -277,11 +276,19 @@ async def getBatchDetails(
277276
):
278277
ip_address = ip_address.exploded
279278

280-
try:
281-
cached_ipaddr = self.cache[cache_key(ip_address)]
282-
result[ip_address] = cached_ipaddr
283-
except KeyError:
284-
lookup_addresses.append(ip_address)
279+
if (
280+
ip_address
281+
and not is_prefixed_lookup(ip_address)
282+
and is_bogon(ip_address)
283+
):
284+
details = {"ip": ip_address, "bogon": True}
285+
result[ip_address] = Details(details)
286+
else:
287+
try:
288+
cached_ipaddr = self.cache[cache_key(ip_address)]
289+
result[ip_address] = cached_ipaddr
290+
except KeyError:
291+
lookup_addresses.append(ip_address)
285292

286293
# all in cache - return early.
287294
if not lookup_addresses:
@@ -296,22 +303,24 @@ async def getBatchDetails(
296303
headers = handler_utils.get_headers(self.access_token, self.headers)
297304
headers["content-type"] = "application/json"
298305

299-
# prepare coroutines that will make reqs and update results.
306+
# prepare tasks that will make reqs and update results.
300307
reqs = [
301-
self._do_batch_req(
302-
lookup_addresses[i : i + batch_size],
303-
url,
304-
headers,
305-
timeout_per_batch,
306-
raise_on_fail,
307-
result,
308+
asyncio.ensure_future(
309+
self._do_batch_req(
310+
lookup_addresses[i : i + batch_size],
311+
url,
312+
headers,
313+
timeout_per_batch,
314+
raise_on_fail,
315+
result,
316+
)
308317
)
309318
for i in range(0, len(lookup_addresses), batch_size)
310319
]
311320

312321
try:
313322
_, pending = await asyncio.wait(
314-
{*reqs},
323+
reqs,
315324
timeout=timeout_total,
316325
return_when=asyncio.FIRST_EXCEPTION,
317326
)
@@ -404,7 +413,11 @@ async def getBatchDetailsIter(
404413
):
405414
ip_address = ip_address.exploded
406415

407-
if ip_address and is_bogon(ip_address):
416+
if (
417+
ip_address
418+
and not is_prefixed_lookup(ip_address)
419+
and is_bogon(ip_address)
420+
):
408421
details = {"ip": ip_address, "bogon": True}
409422
yield Details(details)
410423
else:

ipinfo/handler_utils.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,13 @@ def cache_key(k):
136136
Transforms a user-input key into a versioned cache key.
137137
"""
138138
return f"{k}:{CACHE_KEY_VSN}"
139+
140+
141+
def is_prefixed_lookup(ip_address):
142+
"""
143+
Check if the address is a prefixed batch lookup (e.g., "resproxy/1.2.3.4",
144+
"lookup/8.8.8.8", "domains/google.com").
145+
146+
Prefixed lookups skip bogon checking as they are not plain IP addresses.
147+
"""
148+
return isinstance(ip_address, str) and "/" in ip_address

tests/handler_async_test.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
import sys
44

55
import aiohttp
6-
import ipinfo
76
import pytest
7+
8+
import ipinfo
89
from ipinfo import handler_utils
910
from ipinfo.cache.default import DefaultCache
1011
from ipinfo.details import Details
@@ -367,3 +368,59 @@ def mock_get(*args, **kwargs):
367368
# Verify only one API call was made (second was cached)
368369
assert call_count == 1
369370
await handler.deinit()
371+
372+
373+
class MockBatchResponse(MockResponse):
374+
"""MockResponse with raise_for_status for batch endpoint mocking."""
375+
376+
def raise_for_status(self):
377+
if self.status >= 400:
378+
raise Exception(f"HTTP {self.status}")
379+
380+
381+
@pytest.mark.asyncio
382+
async def test_get_batch_details_with_resproxy(monkeypatch):
383+
"""Prefixed lookups like 'resproxy/IP' should not crash in async getBatchDetails."""
384+
mock_api_response = {
385+
"resproxy/1.2.3.4": {"ip": "1.2.3.4", "service": "example"},
386+
"8.8.8.8": {"ip": "8.8.8.8", "country": "US"},
387+
}
388+
389+
async def mock_post(*args, **kwargs):
390+
return MockBatchResponse(
391+
json.dumps(mock_api_response),
392+
200,
393+
{"Content-Type": "application/json"},
394+
)
395+
396+
handler = AsyncHandler("test_token")
397+
handler._ensure_aiohttp_ready()
398+
monkeypatch.setattr(handler.httpsess, "post", mock_post)
399+
result = await handler.getBatchDetails(["resproxy/1.2.3.4", "8.8.8.8"])
400+
assert "resproxy/1.2.3.4" in result
401+
assert "8.8.8.8" in result
402+
await handler.deinit()
403+
404+
405+
@pytest.mark.asyncio
406+
async def test_get_batch_details_mixed_resproxy_and_bogon(monkeypatch):
407+
"""Async getBatchDetails: mixing prefixed, plain, and bogon IPs."""
408+
mock_api_response = {
409+
"resproxy/1.2.3.4": {"ip": "1.2.3.4", "service": "ex"},
410+
"8.8.8.8": {"ip": "8.8.8.8", "country": "US"},
411+
}
412+
413+
async def mock_post(*args, **kwargs):
414+
return MockBatchResponse(
415+
json.dumps(mock_api_response),
416+
200,
417+
{"Content-Type": "application/json"},
418+
)
419+
420+
handler = AsyncHandler("test_token")
421+
handler._ensure_aiohttp_ready()
422+
monkeypatch.setattr(handler.httpsess, "post", mock_post)
423+
result = await handler.getBatchDetails(["resproxy/1.2.3.4", "8.8.8.8", "127.0.0.1"])
424+
assert "resproxy/1.2.3.4" in result
425+
assert "8.8.8.8" in result
426+
await handler.deinit()

0 commit comments

Comments
 (0)