Skip to content

Commit e760f81

Browse files
authored
✨ Feature: add basic throttling logic (#163)
1 parent 2fb831e commit e760f81

File tree

8 files changed

+136
-67
lines changed

8 files changed

+136
-67
lines changed

docs/installation.md

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -58,32 +58,6 @@ If you want to auth as github app, you should install `auth-app` extra dependenc
5858
pip install githubkit[auth-app]
5959
```
6060

61-
If you want to mix sync and async calls in oauth device callback, you should install `auth-oauth-device` extra dependencies:
62-
63-
=== "poetry"
64-
65-
```bash
66-
poetry add githubkit[auth-oauth-device]
67-
```
68-
69-
=== "pdm"
70-
71-
```bash
72-
pdm add githubkit[auth-oauth-device]
73-
```
74-
75-
=== "uv"
76-
77-
```bash
78-
uv add githubkit[auth-oauth-device]
79-
```
80-
81-
=== "pip"
82-
83-
```bash
84-
pip install githubkit[auth-oauth-device]
85-
```
86-
8761
## Full Installation
8862

8963
You can install fully featured githubkit with `all` extra dependencies:

docs/usage/configuration.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ github = GitHub(
1414
timeout=None,
1515
cache_strategy=None,
1616
http_cache=True,
17+
throttler=None,
1718
auto_retry=True,
1819
rest_api_validate_body=True,
1920
)
@@ -35,6 +36,7 @@ config = Config(
3536
timeout=httpx.Timeout(None),
3637
cache_strategy=DEFAULT_CACHE_STRATEGY,
3738
http_cache=True,
39+
throttler=None,
3840
auto_retry=RETRY_DEFAULT,
3941
rest_api_validate_body=True,
4042
)
@@ -82,6 +84,14 @@ Available built-in cache strategies:
8284

8385
The `http_cache` option enables the http caching feature powered by [Hishel](https://hishel.com/) for HTTPX. GitHub API limits the number of requests that you can make within a specific amount of time. This feature is useful to reduce the number of requests to GitHub API and avoid hitting the rate limit.
8486

87+
### `throttler`
88+
89+
The `throttler` option is used to control the request concurrency to avoid hitting the rate limit. You can provide a githubkit built-in throttler or a custom one that implements the `BaseThrottler` interface. By default, githubkit uses the `LocalThrottler` to control the request concurrency.
90+
91+
Available built-in throttlers:
92+
93+
- `LocalThrottler`: Control the request concurrency in the local process / event loop.
94+
8595
### `auto_retry`
8696

8797
The `auto_retry` option enables request retrying when rate limit exceeded and server error encountered. See [Auto Retry](./auto-retry.md) for more infomation.

githubkit/config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from .retry import RETRY_DEFAULT
88
from .typing import RetryDecisionFunc
9+
from .throttling import BaseThrottler, LocalThrottler
910
from .cache import DEFAULT_CACHE_STRATEGY, BaseCacheStrategy
1011

1112

@@ -18,6 +19,7 @@ class Config:
1819
timeout: httpx.Timeout
1920
cache_strategy: BaseCacheStrategy
2021
http_cache: bool
22+
throttler: BaseThrottler
2123
auto_retry: Optional[RetryDecisionFunc]
2224
rest_api_validate_body: bool
2325

@@ -72,6 +74,14 @@ def build_cache_strategy(
7274
return cache_strategy or DEFAULT_CACHE_STRATEGY
7375

7476

77+
def build_throttler(
78+
throttler: Optional[BaseThrottler],
79+
) -> BaseThrottler:
80+
# https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#about-secondary-rate-limits
81+
# > No more than 100 concurrent requests are allowed
82+
return throttler or LocalThrottler(100)
83+
84+
7585
def build_auto_retry(
7686
auto_retry: Union[bool, RetryDecisionFunc] = True,
7787
) -> Optional[RetryDecisionFunc]:
@@ -93,6 +103,7 @@ def get_config(
93103
timeout: Optional[Union[float, httpx.Timeout]] = None,
94104
cache_strategy: Optional[BaseCacheStrategy] = None,
95105
http_cache: bool = True,
106+
throttler: Optional[BaseThrottler] = None,
96107
auto_retry: Union[bool, RetryDecisionFunc] = True,
97108
rest_api_validate_body: bool = True,
98109
) -> Config:
@@ -104,6 +115,7 @@ def get_config(
104115
build_timeout(timeout),
105116
build_cache_strategy(cache_strategy),
106117
http_cache,
118+
build_throttler(throttler),
107119
build_auto_retry(auto_retry),
108120
rest_api_validate_body,
109121
)

githubkit/core.py

Lines changed: 45 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .utils import UNSET
1414
from .response import Response
1515
from .cache import BaseCacheStrategy
16+
from .throttling import BaseThrottler
1617
from .compat import to_jsonable_python
1718
from .config import Config, get_config
1819
from .auth import BaseAuthStrategy, TokenAuthStrategy, UnauthAuthStrategy
@@ -82,6 +83,7 @@ def __init__(
8283
timeout: Optional[Union[float, httpx.Timeout]] = None,
8384
cache_strategy: Optional[BaseCacheStrategy] = None,
8485
http_cache: bool = True,
86+
throttler: Optional[BaseThrottler] = None,
8587
auto_retry: Union[bool, RetryDecisionFunc] = True,
8688
rest_api_validate_body: bool = True,
8789
): ...
@@ -100,6 +102,7 @@ def __init__(
100102
timeout: Optional[Union[float, httpx.Timeout]] = None,
101103
cache_strategy: Optional[BaseCacheStrategy] = None,
102104
http_cache: bool = True,
105+
throttler: Optional[BaseThrottler] = None,
103106
auto_retry: Union[bool, RetryDecisionFunc] = True,
104107
rest_api_validate_body: bool = True,
105108
): ...
@@ -118,6 +121,7 @@ def __init__(
118121
timeout: Optional[Union[float, httpx.Timeout]] = None,
119122
cache_strategy: Optional[BaseCacheStrategy] = None,
120123
http_cache: bool = True,
124+
throttler: Optional[BaseThrottler] = None,
121125
auto_retry: Union[bool, RetryDecisionFunc] = True,
122126
rest_api_validate_body: bool = True,
123127
): ...
@@ -135,6 +139,7 @@ def __init__(
135139
timeout: Optional[Union[float, httpx.Timeout]] = None,
136140
cache_strategy: Optional[BaseCacheStrategy] = None,
137141
http_cache: bool = True,
142+
throttler: Optional[BaseThrottler] = None,
138143
auto_retry: Union[bool, RetryDecisionFunc] = True,
139144
rest_api_validate_body: bool = True,
140145
):
@@ -152,6 +157,7 @@ def __init__(
152157
timeout=timeout,
153158
cache_strategy=cache_strategy,
154159
http_cache=http_cache,
160+
throttler=throttler,
155161
auto_retry=auto_retry,
156162
rest_api_validate_body=rest_api_validate_body,
157163
)
@@ -271,22 +277,24 @@ def _request(
271277
cookies: Optional[CookieTypes] = None,
272278
) -> httpx.Response:
273279
with self.get_sync_client() as client:
274-
try:
275-
return client.request(
276-
method,
277-
url,
278-
params=params,
279-
content=content,
280-
data=data,
281-
files=files,
282-
json=to_jsonable_python(json),
283-
headers=headers,
284-
cookies=cookies,
285-
)
286-
except httpx.TimeoutException as e:
287-
raise RequestTimeout(e) from e
288-
except Exception as e:
289-
raise RequestError(e) from e
280+
request = client.build_request(
281+
method,
282+
url,
283+
params=params,
284+
content=content,
285+
data=data,
286+
files=files,
287+
json=to_jsonable_python(json),
288+
headers=headers,
289+
cookies=cookies,
290+
)
291+
with self.config.throttler.acquire(request):
292+
try:
293+
return client.send(request)
294+
except httpx.TimeoutException as e:
295+
raise RequestTimeout(e) from e
296+
except Exception as e:
297+
raise RequestError(e) from e
290298

291299
# async request
292300
async def _arequest(
@@ -302,23 +310,27 @@ async def _arequest(
302310
headers: Optional[HeaderTypes] = None,
303311
cookies: Optional[CookieTypes] = None,
304312
) -> httpx.Response:
305-
async with self.get_async_client() as client:
306-
try:
307-
return await client.request(
308-
method,
309-
url,
310-
params=params,
311-
content=content,
312-
data=data,
313-
files=files,
314-
json=to_jsonable_python(json),
315-
headers=headers,
316-
cookies=cookies,
317-
)
318-
except httpx.TimeoutException as e:
319-
raise RequestTimeout(e) from e
320-
except Exception as e:
321-
raise RequestError(e) from e
313+
async with (
314+
self.get_async_client() as client,
315+
):
316+
request = client.build_request(
317+
method,
318+
url,
319+
params=params,
320+
content=content,
321+
data=data,
322+
files=files,
323+
json=to_jsonable_python(json),
324+
headers=headers,
325+
cookies=cookies,
326+
)
327+
async with self.config.throttler.async_acquire(request):
328+
try:
329+
return await client.send(request)
330+
except httpx.TimeoutException as e:
331+
raise RequestTimeout(e) from e
332+
except Exception as e:
333+
raise RequestError(e) from e
322334

323335
# check and parse response
324336
@overload

githubkit/github.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from .config import Config
1818
from .cache import BaseCacheStrategy
19+
from .throttling import BaseThrottler
1920
from .auth import TokenAuthStrategy, UnauthAuthStrategy
2021

2122

@@ -75,6 +76,7 @@ def __init__(
7576
timeout: Optional[Union[float, httpx.Timeout]] = None,
7677
cache_strategy: Optional["BaseCacheStrategy"] = None,
7778
http_cache: bool = True,
79+
throttler: Optional["BaseThrottler"] = None,
7880
auto_retry: Union[bool, RetryDecisionFunc] = True,
7981
rest_api_validate_body: bool = True,
8082
): ...
@@ -93,6 +95,7 @@ def __init__(
9395
timeout: Optional[Union[float, httpx.Timeout]] = None,
9496
cache_strategy: Optional["BaseCacheStrategy"] = None,
9597
http_cache: bool = True,
98+
throttler: Optional["BaseThrottler"] = None,
9699
auto_retry: Union[bool, RetryDecisionFunc] = True,
97100
rest_api_validate_body: bool = True,
98101
): ...
@@ -111,6 +114,7 @@ def __init__(
111114
timeout: Optional[Union[float, httpx.Timeout]] = None,
112115
cache_strategy: Optional["BaseCacheStrategy"] = None,
113116
http_cache: bool = True,
117+
throttler: Optional["BaseThrottler"] = None,
114118
auto_retry: Union[bool, RetryDecisionFunc] = True,
115119
rest_api_validate_body: bool = True,
116120
): ...

githubkit/throttling.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import abc
2+
import threading
3+
from typing import Any, Optional
4+
from typing_extensions import override
5+
from collections.abc import Generator, AsyncGenerator
6+
from contextlib import contextmanager, asynccontextmanager
7+
8+
import anyio
9+
import httpx
10+
11+
12+
class BaseThrottler(abc.ABC):
13+
"""Throttle the number of concurrent requests to avoid hitting rate limits.
14+
15+
See also:
16+
- https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api#avoid-concurrent-requests
17+
- https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#about-secondary-rate-limits
18+
19+
TODO: Implement the pause between mutative requests.
20+
"""
21+
22+
@abc.abstractmethod
23+
@contextmanager
24+
def acquire(self, request: httpx.Request) -> Generator[None, Any, Any]:
25+
raise NotImplementedError
26+
yield
27+
28+
@abc.abstractmethod
29+
@asynccontextmanager
30+
async def async_acquire(self, request: httpx.Request) -> AsyncGenerator[None, Any]:
31+
raise NotImplementedError
32+
yield
33+
34+
35+
class LocalThrottler(BaseThrottler):
36+
def __init__(self, max_concurrency: int) -> None:
37+
self.max_concurrency = max_concurrency
38+
self.semaphore = threading.Semaphore(max_concurrency)
39+
self._async_semaphore: Optional[anyio.Semaphore] = None
40+
41+
@property
42+
def async_semaphore(self) -> anyio.Semaphore:
43+
if self._async_semaphore is None:
44+
self._async_semaphore = anyio.Semaphore(self.max_concurrency)
45+
return self._async_semaphore
46+
47+
@override
48+
@contextmanager
49+
def acquire(self, request: httpx.Request) -> Generator[None, Any, Any]:
50+
with self.semaphore:
51+
yield
52+
53+
@override
54+
@asynccontextmanager
55+
async def async_acquire(self, request: httpx.Request) -> AsyncGenerator[None, Any]:
56+
async with self.async_semaphore:
57+
yield

poetry.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ include = ["githubkit/py.typed"]
1313

1414
[tool.poetry.dependencies]
1515
python = "^3.9"
16+
anyio = ">=3.6.1, <5.0.0"
1617
httpx = ">=0.23.0, <1.0.0"
1718
typing-extensions = "^4.6.0"
1819
hishel = ">=0.0.21, <=0.2.0"
1920
pydantic = ">=1.9.1, <3.0.0, !=2.5.0, !=2.5.1"
20-
anyio = { version = ">=3.6.1, <5.0.0", optional = true }
2121
PyJWT = { version = "^2.4.0", extras = ["crypto"], optional = true }
2222

2323
[tool.poetry.group.dev.dependencies]
@@ -44,9 +44,9 @@ mkdocs-git-revision-date-localized-plugin = "^1.2.9"
4444
[tool.poetry.extras]
4545
jwt = ["PyJWT"]
4646
auth-app = ["PyJWT"]
47-
auth-oauth-device = ["anyio"]
48-
auth = ["PyJWT", "anyio"]
49-
all = ["PyJWT", "anyio"]
47+
auth-oauth-device = [] # backward compatibility
48+
auth = ["PyJWT"]
49+
all = ["PyJWT"]
5050

5151
[tool.pytest.ini_options]
5252
addopts = "--cov=githubkit --cov-append --cov-report=term-missing"

0 commit comments

Comments
 (0)