Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 3.0.1

- Hide the configured API key in printed settings, SDK request logs and error messages, including errors in typed bulk results.
- Keep lookup data and custom headers from being sent to a different server on a redirect.
- Read compressed responses in small chunks. Keep the HTTP status if an error response is too large or cut short.
- Check that typed bulk lookups return one result per input.
- Add Arabic (`ar`) for single and bulk lookups.
- Include CDN, proxy, bot and corporate gateway details in typed results.

## 3.0.0

- Requires Python 3.10 or newer.
Expand Down
22 changes: 6 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,21 +95,12 @@ The async client exposes the same methods as coroutines. Use `async with` for cl
config = IpGeolocationClientConfig(api_key="YOUR_API_KEY")
```

**Request-origin auth**: Paid plans only, and only for single lookup. If your origin is allowlisted in the [IPGeolocation.io dashboard](https://ipgeolocation.io/), the SDK sends it in the `Origin` header.

```python
config = IpGeolocationClientConfig(request_origin="https://app.example.com")
```

`request_origin` must be an absolute `http` or `https` URL with no path, query string, or fragment. Bulk lookups always require `api_key`, even if `request_origin` is set. You can set both on the same config. Single lookups require at least one of them.

### Client Config

| Field | Use |
|---|---|
| `api_key` | API key auth for single lookup and bulk lookup |
| `request_origin` | Paid request-origin auth for single lookup |
| `base_url` | Override the API base URL |
| `base_url` | Use the final address of your API or proxy. The standard client follows redirects only if the protocol, host and port stay the same. The async client stops at redirects by default. |
| `connect_timeout` | Time to wait for the connection in seconds |
| `read_timeout` | Time to wait for the response body in seconds |

Expand All @@ -134,7 +125,6 @@ Responses vary by plan.
| Domain lookup | No | Yes |
| Bulk lookup (`/v3/ipgeo-bulk`) | No | Yes |
| Non-English `lang` | No | Yes |
| Request-origin auth | No | Yes |
| Optional modules via `include` | No | Yes |
| `include=["*"]` | Base response only | All plan-available modules |

Expand Down Expand Up @@ -303,7 +293,7 @@ Bulk lookup accepts the same fields as single lookup, plus:
|---|---|---|
| `ips` | sequence of strings | Required. Up to 50,000 IPs or domains. |

Differences from single lookup: `Content-Type: application/json` is always set by the SDK. Bulk lookup always requires `api_key` in the config, even if `request_origin` is also set.
Set `api_key` in your client settings for bulk lookups. The SDK sets `Content-Type: application/json` for you.

### `include` Values

Expand All @@ -319,14 +309,14 @@ Differences from single lookup: `Content-Type: application/json` is always set b
| `dma_code` | `location.dma_code` |
| `*` | All optional modules available on your plan |

Supported `lang` values: `en`, `de`, `ru`, `ja`, `fr`, `cn`, `es`, `cs`, `it`, `ko`, `fa`, `pt`
Supported `lang` values: `en`, `de`, `ru`, `ja`, `fr`, `cn`, `es`, `cs`, `it`, `ko`, `fa`, `pt`, `ar` (Arabic)

## Response Metadata

Every SDK method returns `ApiResponse(data=..., metadata=...)`.

- Typed single lookup returns `IpGeolocationResponse`
- Typed bulk lookup returns a list of `BulkLookupResult`
- A typed bulk lookup returns one `BulkLookupResult` per input. If the API sends too few or too many results, the SDK raises `SerializationException`.
- Raw methods return the response body as a string

| Field | Type | Description |
Expand All @@ -346,7 +336,7 @@ metadata.first_header_value("Header-Name") # first value or None

### JSON Serialization

Use `to_json()` or `to_pretty_json()` to serialize SDK objects:
Use `to_json()` or `to_pretty_json()` to print SDK objects as JSON. When you print your client settings, the SDK hides your API key.

```python
from ipgeolocation import JsonOutputMode, to_pretty_json
Expand Down Expand Up @@ -387,7 +377,7 @@ All exceptions inherit from `IpGeolocationException`. The SDK does not retry fai
| `ServerErrorException` | 5xx |
| `ApiException` | Any other non-2xx |

All API exceptions have `.status_code` and `.api_message` attributes.
All API exceptions have `.status_code` and `.api_message` attributes. If the error body is too large or the connection closes early, you still get the HTTP status. The SDK hides your API key in error messages, including errors for individual IPs in a typed bulk lookup.

## Links

Expand Down
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "ipgeolocationio"
version = "3.0.0"
version = "3.0.1"
description = "Typed Python SDK for the IPGeolocation.io IP location API, with sync and async clients."
readme = "README.md"
license = "MIT"
Expand Down Expand Up @@ -40,13 +40,15 @@ classifiers = [
dependencies = [
"httpx>=0.28.1,<1",
"requests>=2.33.0,<3",
"urllib3>=2.8.0,<3",
"urllib3[zstd]>=2.8.0,<3",
"anyio>=4.14.2,<5",
"certifi>=2026.10.23",
]

[project.optional-dependencies]
dev = [
"httpx[http2]>=0.28.1,<1",
"urllib3[brotli,zstd]>=2.8.0,<3",
"pytest>=9.0.3,<10",
"pytest-cov>=7.0.0,<8",
"pyright>=1.1.408,<2",
Expand Down
46 changes: 29 additions & 17 deletions src/ipgeolocation/_client_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import Any, Dict, List, Mapping, Optional, Sequence, cast
from urllib.parse import quote

from ._redaction import redact
from ._serde import from_mapping
from .enums import ResponseFormat
from .exceptions import (
Expand Down Expand Up @@ -83,51 +84,56 @@ def resolve_user_agent_header(
def parse_single_lookup(body: str) -> IpGeolocationResponse:
try:
payload = json.loads(body)
except json.JSONDecodeError as exc:
except (ValueError, RecursionError) as exc:
raise SerializationException("Failed to deserialize API response") from exc
if not isinstance(payload, dict):
raise SerializationException("Failed to deserialize API response")
try:
parsed = from_mapping(IpGeolocationResponse, cast(Mapping[str, Any], payload))
except (TypeError, ValueError) as exc:
except (TypeError, ValueError, OverflowError) as exc:
raise SerializationException("Failed to deserialize API response") from exc
if parsed is None: # pragma: no cover - defensive
raise SerializationException("Failed to deserialize API response")
return parsed


def parse_bulk_lookup(body: str) -> List[BulkLookupResult]:
def parse_bulk_lookup(
body: str, expected_count: int, api_key: Optional[str]
) -> List[BulkLookupResult]:
try:
payload = json.loads(body)
except json.JSONDecodeError as exc:
except (ValueError, RecursionError) as exc:
raise SerializationException("Failed to deserialize bulk lookup response") from exc
if not isinstance(payload, list):
raise SerializationException(
"Failed to deserialize bulk response: expected an array payload"
)
if len(payload) != expected_count:
raise SerializationException(
f"Expected {expected_count} bulk results, received {len(payload)}"
)
results: List[BulkLookupResult] = []
for item in payload:
if isinstance(item, dict) and "message" in item and "ip" not in item:
results.append(
BulkLookupError(
error=BulkLookupErrorDetails(message=_string_or_none(item.get("message")))
)
)
message = _string_or_none(item.get("message"))
if message is not None:
message = redact(message, api_key)
results.append(BulkLookupError(error=BulkLookupErrorDetails(message=message)))
else:
if not isinstance(item, Mapping):
raise SerializationException("Failed to deserialize API response")
try:
parsed_item = from_mapping(IpGeolocationResponse, cast(Mapping[str, Any], item))
except (TypeError, ValueError) as exc:
except (TypeError, ValueError, OverflowError) as exc:
raise SerializationException("Failed to deserialize API response") from exc
if parsed_item is None: # pragma: no cover - defensive
raise SerializationException("Failed to deserialize API response")
results.append(BulkLookupSuccess(data=parsed_item))
return results


def to_api_exception(status_code: int, body: str) -> ApiException:
api_message = extract_api_message(body)
def to_api_exception(status_code: int, body: str, api_key: Optional[str] = None) -> ApiException:
api_message = extract_api_message(body, api_key)
if api_message:
message = f"API request failed with HTTP status {status_code}: {api_message}"
else:
Expand All @@ -152,27 +158,33 @@ def to_api_exception(status_code: int, body: str) -> ApiException:
return exception_type(message, status_code, api_message)


def extract_api_message(body: Optional[str]) -> Optional[str]:
def extract_api_message(body: Optional[str], api_key: Optional[str] = None) -> Optional[str]:
if body is None or not str(body).strip():
return None
try:
payload = json.loads(body)
except json.JSONDecodeError:
return body[:512]
return redact(body, api_key)[:512]
except (ValueError, RecursionError):
return None
if isinstance(payload, Mapping):
message = payload.get("message")
if message is None:
error = payload.get("error")
if isinstance(error, Mapping):
message = error.get("message")
return _string_or_none(message)
return body[:512]
text = _string_or_none(message)
return redact(text, api_key) if text is not None else None
return redact(body, api_key)[:512]


def _string_or_none(value: Any) -> Optional[str]:
if value is None:
return None
return str(value)
try:
return str(value)
except (ValueError, RecursionError):
return None


def parse_int_header(value: Optional[str]) -> Optional[int]:
Expand Down
105 changes: 105 additions & 0 deletions src/ipgeolocation/_redaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
from __future__ import annotations

import json
import logging
import re
from contextlib import contextmanager
from contextvars import ContextVar
from functools import lru_cache
from typing import Iterator, Optional
from urllib.parse import quote, quote_plus

from .exceptions import ApiException, IpGeolocationException

MASK = "[REDACTED]"
_active_key: ContextVar[Optional[str]] = ContextVar("ipgeolocation_api_key", default=None)
_percent_escape = re.compile(r"%[0-9a-fA-F]{2}")


def redact(text: str, key: Optional[str]) -> str:
if not key:
return text
for encoded in {quote(key, safe=""), quote_plus(key, safe="")} - {key}:
if "%" in encoded:
normalized = _percent_escape.sub(lambda m: m[0].upper(), text)
pieces: list[str] = []
start = 0
pos = normalized.find(encoded)
while pos >= 0:
pieces.extend((text[start:pos], MASK))
start = pos + len(encoded)
pos = normalized.find(encoded, start)
if pieces:
text = "".join(pieces) + text[start:]
else:
text = text.replace(encoded, MASK)
for escaped in {json.dumps(key)[1:-1], json.dumps(key, ensure_ascii=False)[1:-1]} - {key}:
text = text.replace(escaped, MASK)
return text.replace(key, MASK)


class _RequestLogFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
key = _active_key.get()
if record.name.startswith("hpack."):
if key or (
record.name == "hpack.hpack"
and isinstance(record.msg, str)
and record.msg.startswith(("Decoding %s", "Decoded %s,"))
):
return False
if (
record.name == "hpack.table"
and record.msg == "Evicting %s: %s from the header table"
and isinstance(record.args, tuple)
and len(record.args) == 2
):
record.args = (record.args[0], MASK)
if key:
message = record.getMessage()
safe = redact(message, key)
if safe != message:
record.msg, record.args = safe, ()
return True


_log_filter = _RequestLogFilter()


@lru_cache(maxsize=1)
def _install_log_filter() -> None:
for name in (
"hpack.hpack",
"hpack.table",
"httpx",
"httpcore.connection",
"httpcore.proxy",
"httpcore.socks",
"httpcore.http11",
"httpcore.http2",
"urllib3.connectionpool",
"urllib3.util.retry",
"urllib3.connection",
):
logging.getLogger(name).addFilter(_log_filter)


@contextmanager
def protect_api_key(key: Optional[str]) -> Iterator[None]:
if key is None:
yield
return
_install_log_filter()
token = _active_key.set(key)
try:
yield
except IpGeolocationException as exc:
if isinstance(exc, ApiException) and exc.api_message is not None:
exc.api_message = redact(exc.api_message, key)
exc.args = tuple(redact(arg, key) if isinstance(arg, str) else arg for arg in exc.args)
exc.__cause__ = None
exc.__context__ = None
exc.__suppress_context__ = True
raise
finally:
_active_key.reset(token)
Loading