fix: resolve the browser timezone once per process instead of per request

Every request builds a new InvisiblePlaywright, and its __aenter__ calls
prepare_session_geo(): with the default timezone="" and no proxy that
discovers the egress IP through api.ipify.org, icanhazip.com and
checkip.amazonaws.com, then calls ensure_geoip_mmdb(), which HEADs GitHub
for a newer GeoIP release, three times per session. On a network where
those hosts are slow or blocked every request paid for it before the
browser even started. Measured in the app image: ipify black-holed adds
~5s per request, all three echo hosts black-holed ~12.5s, GitHub
black-holed ~75s. Setting BROWSER_LOCALE alone did not help, because
the timezone was still "auto".

Without a proxy the egress is the host's own, so the answer does not
change between requests. Resolve it on the first request, off the event
loop and under a lock so concurrent first requests share one lookup,
then hand the zone and the locale's primary tag to InvisiblePlaywright
explicitly; an explicit zone and tag make prepare_session_geo touch no
network. The resulting browser is the same as with "auto": passing the
primary tag back reproduces the decided language list for every Firefox
build the locale table can pick, and without a proxy the egress IP,
coordinates and srflx are not used by InvisiblePlaywright anyway.

When the lookup fails upstream falls back to the host timezone by
passing "", but passing "" again would repeat the lookup on every
request, so the host zone is named explicitly (TZ, else the
/etc/localtime link, else UTC) and the lookup is retried after ten
minutes.

Sessions behind a proxy (PROXY_SERVER or X-Proxy-Server) still resolve
per request through the proxy: each proxy has its own egress, and
upstream deliberately fails the launch when it cannot be resolved.

BROWSER_TIMEZONE mirrors BROWSER_LOCALE and wins also behind a proxy;
with both set the lookup is skipped entirely.

Fixes #416

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ThePhaselessandClaude Opus 5.5 committed 2026-10-08 00:05:13 +02:00
1 parent 838704ea23
commit 70d6ced2cb
4 files changed
+196 -1

No files matched your search

+9
View File
@@ -18,6 +18,7 @@
| `PROXY_PASSWORD` | None | Password for proxy authentication. |
| `OWUI_API_KEY` | None | Bearer token for `/load` endpoint authentication. Must match `EXTERNAL_WEB_LOADER_API_KEY` in Open WebUI. |
| `BROWSER_LOCALE` | None | Override the browser's language with a [BCP-47](https://www.rfc-editor.org/rfc/bcp/bcp47.txt) tag, e.g. `en-US`, `de-DE`, `fr-FR`. When unset, the locale is derived from the egress country. |
| `BROWSER_TIMEZONE` | None | Override the browser's timezone with an [IANA](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) name, e.g. `Europe/Berlin`, `Asia/Shanghai`. When unset, the timezone is derived from the egress IP. |
#### Browser language
@@ -25,6 +26,14 @@ Set `BROWSER_LOCALE` to a [BCP-47](https://www.rfc-editor.org/rfc/bcp/bcp47.txt)
Valid tags are maintained in the [IANA Language Subtag Registry](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry). For a friendlier list, see [List of ISO 639-1 codes](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (language) combined with an [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) region code for the full tag, e.g. `pt-BR`.
#### Browser timezone
Set `BROWSER_TIMEZONE` to an [IANA timezone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) like `Europe/Berlin` or `Asia/Shanghai` to fix the browser's timezone. An explicit value is used as is, also behind a proxy.
When either `BROWSER_TIMEZONE` or `BROWSER_LOCALE` is unset, Byparr looks up its public IP through `api.ipify.org`, `icanhazip.com` or `checkip.amazonaws.com` and maps it to a timezone and language with a GeoIP database downloaded from GitHub. Without a proxy this happens once, on the first request, and the result is reused until Byparr restarts (restart it if its public IP moves to another country). If that lookup fails, the host timezone (`TZ`, otherwise `/etc/localtime`, otherwise UTC) and `en-US` are used, and the lookup is retried after 10 minutes. With a proxy (`PROXY_SERVER` or the `X-Proxy-Server` header) it runs for every request, through the proxy.
If those hosts are slow or blocked on your network, set both `BROWSER_TIMEZONE` and `BROWSER_LOCALE`: the lookup is then skipped entirely and no request is made before the browser starts.
## Proxy Recommendation
Recently I've partnered with a _new in town_ proxy service - ProxyBase - to offer affordable proxy services that seems to work seamlessly with Byparr! Using my affiliate code `byparr` (case sensitive!) when signing up will not only get you access to their cost-effective (**$0.69/GB with occasional promotions** _at the time of writing_) proxy network but will also help support the continued development of this project. ProxyBase's proxies can significantly improve your success rate when bypassing anti-bot challenges. [Check out ProxyBase](https://client.proxybase.org/signup?ref=byparr) and enhance your Byparr experience!
+2
View File
@@ -20,6 +20,7 @@ class Settings(BaseSettings):
return_only_cookies: bool = False
owui_api_key: str | None = None
browser_locale: str | None = None
browser_timezone: str | None = None
settings = Settings()
@@ -39,3 +40,4 @@ RETURN_ONLY_COOKIES = settings.return_only_cookies
OWUI_API_KEY = settings.owui_api_key
BROWSER_LOCALE = settings.browser_locale
BROWSER_TIMEZONE = settings.browser_timezone
+59 -1
View File
@@ -1,15 +1,20 @@
import asyncio
import logging
import math
import os
import time
from collections.abc import AsyncGenerator
from typing import Annotated, NamedTuple, cast
from fastapi import Header
from invisible_core import prepare_session_geo
from invisible_playwright.async_api import InvisiblePlaywright
from playwright.async_api import Browser, BrowserContext, Page
from pydantic import BaseModel, Field
from src.consts import (
BROWSER_LOCALE,
BROWSER_TIMEZONE,
LOG_LEVEL,
PROXY_PASSWORD,
PROXY_SERVER,
@@ -52,6 +57,54 @@ class BrowserDepClass(NamedTuple):
context: BrowserContext
GEO_RETRY_SECONDS = 600
class BrowserGeo(NamedTuple):
timezone: str
locale: str
expires_at: float = math.inf
_geo_lock = asyncio.Lock()
_geo: BrowserGeo | None = None
def host_timezone() -> str:
"""IANA zone of the host, the one the browser falls back to."""
if tz := os.environ.get("TZ", "").removeprefix(":"):
return tz
_, found, zone = os.path.realpath("/etc/localtime").partition("/zoneinfo/")
return zone if found else "UTC"
def resolve_browser_geo() -> BrowserGeo:
"""Resolve the timezone and locale of a session without a proxy."""
geo = prepare_session_geo(BROWSER_TIMEZONE or "", None, BROWSER_LOCALE or "auto")
locale = BROWSER_LOCALE or (geo.locale.primary if geo.locale else "en-US")
if geo.timezone:
logger.info("Browser timezone %s, locale %s", geo.timezone, locale)
return BrowserGeo(geo.timezone, locale)
timezone = host_timezone()
logger.warning(
"Could not resolve the timezone from the egress IP, using %s and locale %s "
"for %d seconds. Set BROWSER_TIMEZONE and BROWSER_LOCALE to skip the lookup.",
timezone,
locale,
GEO_RETRY_SECONDS,
)
return BrowserGeo(timezone, locale, time.monotonic() + GEO_RETRY_SECONDS)
async def get_browser_geo() -> BrowserGeo:
"""Per-process cache of resolve_browser_geo()."""
global _geo # noqa: PLW0603
async with _geo_lock:
if _geo is None or time.monotonic() >= _geo.expires_at:
_geo = await asyncio.to_thread(resolve_browser_geo)
return _geo
async def get_browser(
x_proxy_server: Annotated[
str | None,
@@ -79,6 +132,8 @@ async def get_browser(
header_password = x_proxy_password
proxy_config = None
timezone = BROWSER_TIMEZONE or ""
locale = BROWSER_LOCALE or "auto"
if header_server:
proxy_config = {
@@ -92,12 +147,15 @@ async def get_browser(
"username": PROXY_USERNAME,
"password": PROXY_PASSWORD,
}
else:
timezone, locale, _ = await get_browser_geo()
async with InvisiblePlaywright(
headless=True,
proxy=proxy_config,
humanize=True,
locale=BROWSER_LOCALE or "auto",
locale=locale,
timezone=timezone,
extra_prefs={
"devtools.jsonview.enabled": False,
"browser.tabs.remote.useCrossOriginOpenerPolicy": False,
+126
View File
@@ -0,0 +1,126 @@
import asyncio
import math
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from invisible_core import SessionGeo, SessionLocale
from src import utils
BERLIN = SessionGeo(
"Europe/Berlin",
"203.0.113.7",
locale=SessionLocale(languages=("de", "en-US", "en"), region="DE"),
)
FAILED = SessionGeo(
"", None, locale=SessionLocale(languages=("en-US", "en"), region="US")
)
@pytest.fixture(autouse=True)
def fresh_geo_cache(monkeypatch: pytest.MonkeyPatch):
"""Every test starts with nothing resolved."""
monkeypatch.setattr(utils, "_geo", None)
monkeypatch.setattr(utils, "_geo_lock", asyncio.Lock())
def test_host_timezone_prefers_tz_env(monkeypatch: pytest.MonkeyPatch):
"""TZ wins over /etc/localtime, with or without the leading colon."""
monkeypatch.setenv("TZ", ":Asia/Shanghai")
assert utils.host_timezone() == "Asia/Shanghai"
def test_host_timezone_reads_localtime_symlink(monkeypatch: pytest.MonkeyPatch):
"""Without TZ the zone comes from where /etc/localtime points."""
monkeypatch.delenv("TZ", raising=False)
with patch(
"src.utils.os.path.realpath", return_value="/usr/share/zoneinfo/Europe/Warsaw"
):
assert utils.host_timezone() == "Europe/Warsaw"
def test_host_timezone_defaults_to_utc(monkeypatch: pytest.MonkeyPatch):
"""No TZ and no zoneinfo link is what the Docker image has: UTC."""
monkeypatch.delenv("TZ", raising=False)
with patch("src.utils.os.path.realpath", return_value="/etc/localtime"):
assert utils.host_timezone() == "UTC"
@pytest.mark.asyncio
async def test_egress_lookup_runs_once_per_process():
"""Concurrent first requests and every later one share a single lookup."""
with patch("src.utils.prepare_session_geo", return_value=BERLIN) as lookup:
results = await asyncio.gather(*(utils.get_browser_geo() for _ in range(3)))
results.append(await utils.get_browser_geo())
lookup.assert_called_once_with("", None, "auto")
assert set(results) == {utils.BrowserGeo("Europe/Berlin", "de", math.inf)}
@pytest.mark.asyncio
async def test_failed_lookup_uses_host_timezone_then_retries(
monkeypatch: pytest.MonkeyPatch,
):
"""A failed lookup is not repaid per request, but is retried later."""
monkeypatch.setenv("TZ", "Asia/Shanghai")
with patch("src.utils.prepare_session_geo", return_value=FAILED) as lookup:
geo = await utils.get_browser_geo()
assert (geo.timezone, geo.locale) == ("Asia/Shanghai", "en-US")
await utils.get_browser_geo()
assert lookup.call_count == 1
monkeypatch.setattr(utils, "_geo", geo._replace(expires_at=0))
await utils.get_browser_geo()
assert lookup.call_count == 2 # noqa: PLR2004
@pytest.mark.asyncio
async def test_explicit_timezone_and_locale_touch_no_network():
"""BROWSER_TIMEZONE with BROWSER_LOCALE needs neither egress IP nor geoip."""
with (
patch("src.utils.BROWSER_TIMEZONE", "Asia/Shanghai"),
patch("src.utils.BROWSER_LOCALE", "zh-CN"),
patch("invisible_core._geo.discover_egress_ip") as discover,
patch("invisible_core.download.ensure_geoip_mmdb") as geoip,
):
geo = await utils.get_browser_geo()
assert (geo.timezone, geo.locale) == ("Asia/Shanghai", "zh-CN")
discover.assert_not_called()
geoip.assert_not_called()
def fake_invisible_playwright() -> MagicMock:
"""Stand-in for InvisiblePlaywright that records how it was built."""
browser = MagicMock()
browser.new_context = AsyncMock(return_value=AsyncMock())
session = MagicMock()
session.__aenter__ = AsyncMock(return_value=browser)
session.__aexit__ = AsyncMock(return_value=False)
return MagicMock(return_value=session)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("proxy_header", "expected"),
[
(None, {"timezone": "Europe/Berlin", "locale": "de"}),
("socks5://proxy.test:1080", {"timezone": "", "locale": "auto"}),
],
)
async def test_proxy_sessions_resolve_their_own_egress(
proxy_header: str | None, expected: dict[str, str]
):
"""The cache only covers direct sessions; a proxy's egress is its own."""
launcher = fake_invisible_playwright()
with (
patch("src.utils.PROXY_SERVER", None),
patch("src.utils.InvisiblePlaywright", launcher),
patch("src.utils.prepare_session_geo", return_value=BERLIN) as lookup,
):
async for _ in utils.get_browser(x_proxy_server=proxy_header):
pass
kwargs = launcher.call_args.kwargs
assert {"timezone": kwargs["timezone"], "locale": kwargs["locale"]} == expected
assert lookup.called is (proxy_header is None)