Skip to content

Commit 4fc6177

Browse files
authored
fix: avoid native rate limiters in Pyodide (#124)
1 parent 0741a32 commit 4fc6177

4 files changed

Lines changed: 133 additions & 53 deletions

File tree

docs/webworker.js

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,48 @@
55
// and `.wasm` files as well:
66
importScripts("https://cdn.jsdelivr.net/pyodide/v0.25.1/full/pyodide.js");
77

8+
function installLegacyWenxianBrowserShims() {
9+
self.pyodide.runPython(`
10+
from importlib.metadata import version
11+
import sys
12+
import types
13+
14+
from packaging.version import Version
15+
16+
if Version(version("wenxian")) < Version("0.3.4"):
17+
class _NativeOnly:
18+
def __init__(self, *args, **kwargs):
19+
pass
20+
21+
pyrate_limiter = types.ModuleType("pyrate_limiter")
22+
pyrate_limiter.Duration = types.SimpleNamespace(SECOND=1)
23+
pyrate_limiter.Limiter = _NativeOnly
24+
pyrate_limiter.Rate = _NativeOnly
25+
26+
requests_ratelimiter = types.ModuleType("requests_ratelimiter")
27+
requests_ratelimiter.__path__ = []
28+
requests_ratelimiter.LimiterAdapter = _NativeOnly
29+
30+
requests_ratelimiter_impl = types.ModuleType(
31+
"requests_ratelimiter.requests_ratelimiter"
32+
)
33+
requests_ratelimiter_impl.HostBucketFactory = _NativeOnly
34+
requests_ratelimiter.requests_ratelimiter = requests_ratelimiter_impl
35+
36+
sys.modules["pyrate_limiter"] = pyrate_limiter
37+
sys.modules["requests_ratelimiter"] = requests_ratelimiter
38+
sys.modules["requests_ratelimiter.requests_ratelimiter"] = (
39+
requests_ratelimiter_impl
40+
)
41+
`);
42+
}
43+
844
async function loadPyodideAndPackages() {
945
self.pyodide = await loadPyodide();
1046
await self.pyodide.loadPackage("micropip");
1147
const micropip = self.pyodide.pyimport("micropip");
12-
await micropip.install([
13-
"pyrate-limiter>=3",
14-
"wenxian",
15-
"pylatexenc==3.0a21",
16-
]);
48+
await micropip.install(["wenxian", "pylatexenc==3.0a21"]);
49+
installLegacyWenxianBrowserShims();
1750
await self.pyodide.loadPackage("sqlite3");
1851
}
1952
let pyodideReadyPromise = loadPyodideAndPackages();

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ dependencies = [
2424
"pylatexenc",
2525
"unidecode",
2626
"pyiso4",
27-
"requests-ratelimiter",
28-
"pyrate-limiter>=3",
27+
"requests-ratelimiter; sys_platform != 'emscripten'",
28+
"pyrate-limiter>=3; sys_platform != 'emscripten'",
2929
]
3030
requires-python = ">=3.10"
3131
readme = "README.md"

tests/test_browser_session.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Browser-runtime tests for HTTP session setup."""
2+
3+
from __future__ import annotations
4+
5+
import builtins
6+
import runpy
7+
import sys
8+
9+
import pytest
10+
11+
from wenxian.feeder import session
12+
13+
14+
def test_browser_session_skips_native_rate_limiters(monkeypatch):
15+
"""Test importing the browser path never loads native-only limiters."""
16+
session_path = session.__file__
17+
assert session_path is not None
18+
native_import = builtins.__import__
19+
20+
def guarded_import(name, *args, **kwargs):
21+
if name.split(".", maxsplit=1)[0] in {
22+
"pyrate_limiter",
23+
"requests_ratelimiter",
24+
}:
25+
raise AssertionError(f"browser path imported native package {name}")
26+
return native_import(name, *args, **kwargs)
27+
28+
with monkeypatch.context() as browser:
29+
browser.setattr(sys, "platform", "emscripten")
30+
browser.setattr(builtins, "__import__", guarded_import)
31+
namespace = runpy.run_path(session_path)
32+
33+
with pytest.raises(RuntimeError, match="Synchronous HTTP is unavailable"):
34+
namespace["SESSION"].get("https://example.test")

wenxian/feeder/session.py

Lines changed: 59 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@
99
from typing import TYPE_CHECKING
1010
from urllib.parse import urlencode
1111

12-
from pyrate_limiter import Duration, Limiter, Rate
13-
from requests import Session
14-
from requests.adapters import HTTPAdapter, Retry
15-
from requests_ratelimiter import LimiterAdapter
16-
from requests_ratelimiter.requests_ratelimiter import HostBucketFactory
17-
1812
if TYPE_CHECKING:
1913
from asyncio import AbstractEventLoop
2014
from collections.abc import Mapping
2115
from typing import Any
2216

17+
if sys.platform != "emscripten":
18+
from pyrate_limiter import Duration, Limiter, Rate
19+
from requests import Session
20+
from requests.adapters import HTTPAdapter, Retry
21+
from requests_ratelimiter import LimiterAdapter
22+
from requests_ratelimiter.requests_ratelimiter import HostBucketFactory
23+
2324

2425
@dataclass
2526
class _BrowserResponse:
@@ -63,48 +64,60 @@ async def wait(self) -> None:
6364
state.next_start = loop.time() + self.interval
6465

6566

66-
_DEFAULT_TIMEOUT = (5.0, 20.0)
67-
68-
69-
class _TimeoutSession(Session):
70-
"""Requests session that applies a bounded timeout by default."""
71-
72-
def request(self, method, url, **kwargs):
73-
"""Send a request with the shared default timeout unless overridden."""
74-
kwargs.setdefault("timeout", _DEFAULT_TIMEOUT)
75-
return super().request(method, url, **kwargs)
67+
class _BrowserSession:
68+
"""Reject synchronous HTTP calls in browser runtimes."""
69+
70+
def get(self, *args: Any, **kwargs: Any) -> Any:
71+
"""Raise because Pyodide networking must use the asynchronous transport."""
72+
raise RuntimeError(
73+
"Synchronous HTTP is unavailable in Pyodide; use async_get instead."
74+
)
75+
76+
77+
if sys.platform != "emscripten":
78+
_DEFAULT_TIMEOUT = (5.0, 20.0)
79+
80+
class _TimeoutSession(Session):
81+
"""Requests session that applies a bounded timeout by default."""
82+
83+
def request(self, method, url, **kwargs):
84+
"""Send a request with the shared default timeout unless overridden."""
85+
kwargs.setdefault("timeout", _DEFAULT_TIMEOUT)
86+
return super().request(method, url, **kwargs)
87+
88+
SESSION = _TimeoutSession()
89+
90+
# retry logic
91+
retries = Retry(
92+
total=5,
93+
backoff_factor=0.1,
94+
status_forcelist=[
95+
429,
96+
500,
97+
502,
98+
503,
99+
504,
100+
],
101+
)
102+
103+
adapter_ncbi = LimiterAdapter(per_second=3, max_retries=retries)
104+
SESSION.mount("https://www.ncbi.nlm.nih.gov/pmc/utils/", adapter_ncbi)
105+
SESSION.mount("https://eutils.ncbi.nlm.nih.gov/", adapter_ncbi)
106+
adapter_crossref = LimiterAdapter(per_second=50, max_retries=retries)
107+
SESSION.mount("https://api.crossref.org/", adapter_crossref)
108+
adapter_arxiv = LimiterAdapter(
109+
limiter=Limiter(HostBucketFactory([Rate(1, Duration.SECOND * 3)])),
110+
burst=1,
111+
max_retries=retries,
112+
)
113+
SESSION.mount("https://export.arxiv.org/api", adapter_arxiv)
114+
adapter_semanticscholar = LimiterAdapter(per_second=1, max_retries=retries)
115+
SESSION.mount("https://api.semanticscholar.org/", adapter_semanticscholar)
116+
SESSION.mount("https://", HTTPAdapter(max_retries=retries))
117+
else:
118+
SESSION = _BrowserSession()
76119

77120

78-
SESSION = _TimeoutSession()
79-
80-
# retry logic
81-
retries = Retry(
82-
total=5,
83-
backoff_factor=0.1,
84-
status_forcelist=[
85-
429,
86-
500,
87-
502,
88-
503,
89-
504,
90-
],
91-
)
92-
93-
adapter_ncbi = LimiterAdapter(per_second=3, max_retries=retries)
94-
SESSION.mount("https://www.ncbi.nlm.nih.gov/pmc/utils/", adapter_ncbi)
95-
SESSION.mount("https://eutils.ncbi.nlm.nih.gov/", adapter_ncbi)
96-
adapter_crossref = LimiterAdapter(per_second=50, max_retries=retries)
97-
SESSION.mount("https://api.crossref.org/", adapter_crossref)
98-
adapter_arxiv = LimiterAdapter(
99-
limiter=Limiter(HostBucketFactory([Rate(1, Duration.SECOND * 3)])),
100-
burst=1,
101-
max_retries=retries,
102-
)
103-
SESSION.mount("https://export.arxiv.org/api", adapter_arxiv)
104-
adapter_semanticscholar = LimiterAdapter(per_second=1, max_retries=retries)
105-
SESSION.mount("https://api.semanticscholar.org/", adapter_semanticscholar)
106-
SESSION.mount("https://", HTTPAdapter(max_retries=retries))
107-
108121
_BROWSER_RETRY_STATUSES = {429, 500, 502, 503, 504}
109122
_BROWSER_TIMEOUT = 20.0
110123
_BROWSER_RETRIES = 2

0 commit comments

Comments
 (0)