4 Commits
Author SHA1 Message Date
dsql a5b91bed0d fix: state slug uses underscore + ascii-fold to match the proxy region- contract
addr.geo.fetch_location produced a hyphen slug with no unicode fold (new-york / québec), but the live proxy region- token the original utils.py fed expects underscore + ascii-fold (new_york / quebec). a multi-word state silently routed to an unrecognized region with no error. added _state_slug (NFKD ascii-fold, lowercase, spaces->underscore) and routed _parse_reverse through it. bump to v0.2.2.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 03:25:14 -04:00
dsql 83a156fd31 fix: forward per-request timeout to geo lookups on an injected session
_get_json applied the timeout only when it created the session; when the caller passed their own session=, the timeout was silently dropped and the session default (aiohttp's 300s) governed. the per-request timeout is now passed to session.get(timeout=...) on both the owned and injected paths.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 01:10:25 -04:00
dsql de6911fb05 fix: retry log uses total attempts as the denominator
the retry warning logged index/last_index (attempts-1), so a 3-attempt retry showed 'retry 1/2'. now logs index+1 of attempts. both retry and aretry.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-28 17:18:28 -04:00
dsql c6e3dd1b54 fix: floor retry/aretry attempts at 1 (v0.2.1)
retry(fn, attempts=0) (or negative) silently returned None without ever calling fn,
looking like success. floor attempts at max(1, attempts) so the callable always runs
at least once; a failing call now fails loud after one try instead of no-op None.

verified: attempts=0/-5 -> 1 call (sync + async); failing fn raises after 1 try;
31/31 retry regression intact.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-28 16:11:34 -04:00
5 changed files with 20 additions and 8 deletions
+2 -2
View File
@@ -12,9 +12,9 @@ Small sync helpers shared across projects. Base is stdlib only — **no dependen
## Install ## Install
``` ```
commons @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.2.0 commons @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.2.2
# async address/geo lookups (fetch_ip / ip_location / fetch_location) need the extra: # async address/geo lookups (fetch_ip / ip_location / fetch_location) need the extra:
commons[addr] @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.2.0 commons[addr] @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.2.2
``` ```
The base install pulls **nothing** (stdlib). Only `commons[addr]` adds `aiohttp`, and The base install pulls **nothing** (stdlib). Only `commons[addr]` adds `aiohttp`, and
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "commons" name = "commons"
version = "0.2.0" version = "0.2.2"
description = "small stdlib-only sync helpers: time/timezone deltas, dotted-path dict access, display masking, ip/address tooling, and retry/backoff" description = "small stdlib-only sync helpers: time/timezone deltas, dotted-path dict access, display masking, ip/address tooling, and retry/backoff"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [] dependencies = []
+1 -1
View File
@@ -63,4 +63,4 @@ __all__ = [
"aretry", "aretry",
] ]
__version__ = "0.2.0" __version__ = "0.2.2"
+10 -2
View File
@@ -12,6 +12,7 @@ security: the only secret is geo.ipify's `api_key`, which is a REQUIRED keyword
`ip_location` — the caller injects it. nothing is hardcoded here. `ip_location` — the caller injects it. nothing is hardcoded here.
""" """
import logging import logging
import unicodedata
from typing import Optional from typing import Optional
from urllib.parse import urlencode from urllib.parse import urlencode
@@ -55,6 +56,12 @@ def _parse_ipify(data: dict) -> Optional[str]:
return ip or None return ip or None
def _state_slug(state: str) -> str:
"""lowercase ascii-folded state slug with underscores, matching the live proxy region- contract"""
folded = unicodedata.normalize("NFKD", state).encode("ascii", "ignore").decode("ascii")
return folded.lower().replace(" ", "_")
def _parse_reverse(data: dict) -> Optional[dict]: def _parse_reverse(data: dict) -> Optional[dict]:
"""parse a nominatim reverse response into {country: iso2 lower, state: slug|None}""" """parse a nominatim reverse response into {country: iso2 lower, state: slug|None}"""
address = data.get("address") address = data.get("address")
@@ -66,7 +73,7 @@ def _parse_reverse(data: dict) -> Optional[dict]:
state = address.get("state") state = address.get("state")
return { return {
"country": str(country).lower(), "country": str(country).lower(),
"state": state.lower().replace(" ", "-") if state else None, "state": _state_slug(state) if state else None,
} }
@@ -80,7 +87,8 @@ async def _get_json(
if owns: if owns:
session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout))
try: try:
async with session.get(url, headers=headers) as resp: request_timeout = aiohttp.ClientTimeout(total=timeout)
async with session.get(url, headers=headers, timeout=request_timeout) as resp:
if resp.status != 200: if resp.status != 200:
log.warning("address lookup %s -> %s", url, resp.status) log.warning("address lookup %s -> %s", url, resp.status)
return None return None
+6 -2
View File
@@ -77,8 +77,10 @@ def retry(
`retry(fn, ...)` runs immediately; `@retry(...)` wraps a function. retries on the `retry(fn, ...)` runs immediately; `@retry(...)` wraps a function. retries on the
`on` exceptions, stops early if `give_up(exc)` is true, re-raises the last `on` exceptions, stops early if `give_up(exc)` is true, re-raises the last
exception once `attempts` are exhausted. `sleep`/`rand` are injectable for tests. exception once `attempts` are exhausted. `sleep`/`rand` are injectable for tests.
`attempts` is floored at 1 so the callable always runs at least once.
""" """
types = _as_types(on) types = _as_types(on)
attempts = max(1, attempts)
def run(target: Callable, args, kwargs): def run(target: Callable, args, kwargs):
delays = list(_delays(attempts, backoff, factor, max_backoff)) delays = list(_delays(attempts, backoff, factor, max_backoff))
@@ -94,7 +96,7 @@ def retry(
wait = _jittered(delays[index], jitter, rand) wait = _jittered(delays[index], jitter, rand)
log.warning( log.warning(
"retry %d/%d after %s: %s", "retry %d/%d after %s: %s",
index + 1, last_index, type(exc).__name__, exc, index + 1, attempts, type(exc).__name__, exc,
) )
if wait > 0: if wait > 0:
sleep(wait) sleep(wait)
@@ -128,8 +130,10 @@ def aretry(
async twin of `retry`. `await aretry(coro_fn, ...)` runs immediately; async twin of `retry`. `await aretry(coro_fn, ...)` runs immediately;
`@aretry(...)` wraps a coroutine function. same semantics: retry on `on`, stop on `@aretry(...)` wraps a coroutine function. same semantics: retry on `on`, stop on
`give_up`, re-raise the last exception after `attempts`. `sleep`/`rand` injectable. `give_up`, re-raise the last exception after `attempts`. `sleep`/`rand` injectable.
`attempts` is floored at 1 so the callable always runs at least once.
""" """
types = _as_types(on) types = _as_types(on)
attempts = max(1, attempts)
async def run(target: Callable, args, kwargs): async def run(target: Callable, args, kwargs):
delays = list(_delays(attempts, backoff, factor, max_backoff)) delays = list(_delays(attempts, backoff, factor, max_backoff))
@@ -145,7 +149,7 @@ def aretry(
wait = _jittered(delays[index], jitter, rand) wait = _jittered(delays[index], jitter, rand)
log.warning( log.warning(
"retry %d/%d after %s: %s", "retry %d/%d after %s: %s",
index + 1, last_index, type(exc).__name__, exc, index + 1, attempts, type(exc).__name__, exc,
) )
if wait > 0: if wait > 0:
await sleep(wait) await sleep(wait)