Skip to content

Commit 85fd525

Browse files
authored
Merge pull request #118 from ipinfo/silvano/eng-640-add-resproxy-support-in-ipinfopython-library
Add Residential Proxy API support
2 parents ff42b7a + 7e24f77 commit 85fd525

File tree

5 files changed

+172
-0
lines changed

5 files changed

+172
-0
lines changed

ipinfo/handler.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .exceptions import RequestQuotaExceededError, TimeoutExceededError
1414
from .handler_utils import (
1515
API_URL,
16+
RESPROXY_API_URL,
1617
BATCH_MAX_SIZE,
1718
CACHE_MAXSIZE,
1819
CACHE_TTL,
@@ -145,6 +146,57 @@ def getDetails(self, ip_address=None, timeout=None):
145146

146147
return Details(details)
147148

149+
def getResproxy(self, ip_address, timeout=None):
150+
"""
151+
Get residential proxy information for specified IP address.
152+
153+
Returns a Details object containing:
154+
- ip: The IP address
155+
- last_seen: The last recorded date when the proxy was active (YYYY-MM-DD)
156+
- percent_days_seen: Percentage of days active in the last 7-day period
157+
- service: Name of the residential proxy service
158+
159+
If `timeout` is not `None`, it will override the client-level timeout
160+
just for this operation.
161+
"""
162+
if isinstance(ip_address, IPv4Address) or isinstance(ip_address, IPv6Address):
163+
ip_address = ip_address.exploded
164+
165+
# check cache first.
166+
cache_key_str = f"resproxy:{ip_address}"
167+
try:
168+
cached_data = self.cache[cache_key(cache_key_str)]
169+
return Details(cached_data)
170+
except KeyError:
171+
pass
172+
173+
# prepare req http opts
174+
req_opts = {**self.request_options}
175+
if timeout is not None:
176+
req_opts["timeout"] = timeout
177+
178+
# do http req
179+
url = f"{RESPROXY_API_URL}/{ip_address}"
180+
headers = handler_utils.get_headers(self.access_token, self.headers)
181+
response = requests.get(url, headers=headers, **req_opts)
182+
if response.status_code == 429:
183+
raise RequestQuotaExceededError()
184+
if response.status_code >= 400:
185+
error_code = response.status_code
186+
content_type = response.headers.get("Content-Type")
187+
if content_type == "application/json":
188+
error_response = response.json()
189+
else:
190+
error_response = {"error": response.text}
191+
raise APIError(error_code, error_response)
192+
details = response.json()
193+
194+
# cache result
195+
self.cache[cache_key(cache_key_str)] = details
196+
197+
return Details(details)
198+
199+
148200
def getBatchDetails(
149201
self,
150202
ip_addresses,

ipinfo/handler_async.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from .exceptions import RequestQuotaExceededError, TimeoutExceededError
1616
from .handler_utils import (
1717
API_URL,
18+
RESPROXY_API_URL,
1819
BATCH_MAX_SIZE,
1920
CACHE_MAXSIZE,
2021
CACHE_TTL,
@@ -167,6 +168,56 @@ async def getDetails(self, ip_address=None, timeout=None):
167168

168169
return Details(details)
169170

171+
async def getResproxy(self, ip_address, timeout=None):
172+
"""
173+
Get residential proxy information for specified IP address.
174+
175+
Returns a Details object containing:
176+
- ip: The IP address
177+
- last_seen: The last recorded date when the proxy was active (YYYY-MM-DD)
178+
- percent_days_seen: Percentage of days active in the last 7-day period
179+
- service: Name of the residential proxy service
180+
181+
If `timeout` is not `None`, it will override the client-level timeout
182+
just for this operation.
183+
"""
184+
self._ensure_aiohttp_ready()
185+
186+
if isinstance(ip_address, IPv4Address) or isinstance(ip_address, IPv6Address):
187+
ip_address = ip_address.exploded
188+
189+
# check cache first.
190+
cache_key_str = f"resproxy:{ip_address}"
191+
try:
192+
cached_data = self.cache[cache_key(cache_key_str)]
193+
return Details(cached_data)
194+
except KeyError:
195+
pass
196+
197+
# do http req
198+
url = f"{RESPROXY_API_URL}/{ip_address}"
199+
headers = handler_utils.get_headers(self.access_token, self.headers)
200+
req_opts = {}
201+
if timeout is not None:
202+
req_opts["timeout"] = timeout
203+
async with self.httpsess.get(url, headers=headers, **req_opts) as resp:
204+
if resp.status == 429:
205+
raise RequestQuotaExceededError()
206+
if resp.status >= 400:
207+
error_code = resp.status
208+
content_type = resp.headers.get("Content-Type")
209+
if content_type == "application/json":
210+
error_response = await resp.json()
211+
else:
212+
error_response = {"error": resp.text()}
213+
raise APIError(error_code, error_response)
214+
details = await resp.json()
215+
216+
# cache result
217+
self.cache[cache_key(cache_key_str)] = details
218+
219+
return Details(details)
220+
170221
async def getBatchDetails(
171222
self,
172223
ip_addresses,

ipinfo/handler_utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
# Base URL for the IPinfo Plus API (same as Core)
2222
PLUS_API_URL = "https://api.ipinfo.io/lookup"
2323

24+
# Base URL for the IPinfo Residential Proxy API
25+
RESPROXY_API_URL = "https://ipinfo.io/resproxy"
26+
2427
# Base URL to get country flag image link.
2528
# "PK" -> "https://cdn.ipinfo.io/static/images/countries-flags/PK.svg"
2629
COUNTRY_FLAGS_URL = "https://cdn.ipinfo.io/static/images/countries-flags/"

tests/handler_async_test.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,3 +252,38 @@ async def test_bogon_details():
252252
handler = AsyncHandler(token)
253253
details = await handler.getDetails("127.0.0.1")
254254
assert details.all == {"bogon": True, "ip": "127.0.0.1"}
255+
256+
257+
#################
258+
# RESPROXY TESTS
259+
#################
260+
261+
262+
@pytest.mark.asyncio
263+
async def test_get_resproxy():
264+
token = os.environ.get("IPINFO_TOKEN", "")
265+
if not token:
266+
pytest.skip("token required for resproxy tests")
267+
handler = AsyncHandler(token)
268+
# Use an IP known to be a residential proxy (from API documentation)
269+
details = await handler.getResproxy("175.107.211.204")
270+
assert isinstance(details, Details)
271+
assert details.ip == "175.107.211.204"
272+
assert details.last_seen is not None
273+
assert details.percent_days_seen is not None
274+
assert details.service is not None
275+
await handler.deinit()
276+
277+
278+
@pytest.mark.asyncio
279+
async def test_get_resproxy_caching():
280+
token = os.environ.get("IPINFO_TOKEN", "")
281+
if not token:
282+
pytest.skip("token required for resproxy tests")
283+
handler = AsyncHandler(token)
284+
# First call should hit the API
285+
details1 = await handler.getResproxy("175.107.211.204")
286+
# Second call should hit the cache
287+
details2 = await handler.getResproxy("175.107.211.204")
288+
assert details1.ip == details2.ip
289+
await handler.deinit()

tests/handler_test.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,34 @@ def test_iterative_bogon_details():
236236
handler = Handler(token)
237237
details = next(handler.getBatchDetailsIter(["127.0.0.1"]))
238238
assert details.all == {"bogon": True, "ip": "127.0.0.1"}
239+
240+
241+
#################
242+
# RESPROXY TESTS
243+
#################
244+
245+
246+
def test_get_resproxy():
247+
token = os.environ.get("IPINFO_TOKEN", "")
248+
if not token:
249+
pytest.skip("token required for resproxy tests")
250+
handler = Handler(token)
251+
# Use an IP known to be a residential proxy (from API documentation)
252+
details = handler.getResproxy("175.107.211.204")
253+
assert isinstance(details, Details)
254+
assert details.ip == "175.107.211.204"
255+
assert details.last_seen is not None
256+
assert details.percent_days_seen is not None
257+
assert details.service is not None
258+
259+
260+
def test_get_resproxy_caching():
261+
token = os.environ.get("IPINFO_TOKEN", "")
262+
if not token:
263+
pytest.skip("token required for resproxy tests")
264+
handler = Handler(token)
265+
# First call should hit the API
266+
details1 = handler.getResproxy("175.107.211.204")
267+
# Second call should hit the cache
268+
details2 = handler.getResproxy("175.107.211.204")
269+
assert details1.ip == details2.ip

0 commit comments

Comments
 (0)