Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: add basic throttling logic #163

Merged
merged 3 commits into from
Nov 18, 2024
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
26 changes: 0 additions & 26 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,32 +58,6 @@ If you want to auth as github app, you should install `auth-app` extra dependenc
pip install githubkit[auth-app]
```

If you want to mix sync and async calls in oauth device callback, you should install `auth-oauth-device` extra dependencies:

=== "poetry"

```bash
poetry add githubkit[auth-oauth-device]
```

=== "pdm"

```bash
pdm add githubkit[auth-oauth-device]
```

=== "uv"

```bash
uv add githubkit[auth-oauth-device]
```

=== "pip"

```bash
pip install githubkit[auth-oauth-device]
```

## Full Installation

You can install fully featured githubkit with `all` extra dependencies:
Expand Down
10 changes: 10 additions & 0 deletions docs/usage/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ github = GitHub(
timeout=None,
cache_strategy=None,
http_cache=True,
throttler=None,
auto_retry=True,
rest_api_validate_body=True,
)
Expand All @@ -35,6 +36,7 @@ config = Config(
timeout=httpx.Timeout(None),
cache_strategy=DEFAULT_CACHE_STRATEGY,
http_cache=True,
throttler=None,
auto_retry=RETRY_DEFAULT,
rest_api_validate_body=True,
)
Expand Down Expand Up @@ -82,6 +84,14 @@ Available built-in cache strategies:

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.

### `throttler`

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.

Available built-in throttlers:

- `LocalThrottler`: Control the request concurrency in the local process / event loop.

### `auto_retry`

The `auto_retry` option enables request retrying when rate limit exceeded and server error encountered. See [Auto Retry](./auto-retry.md) for more infomation.
Expand Down
12 changes: 12 additions & 0 deletions githubkit/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from .retry import RETRY_DEFAULT
from .typing import RetryDecisionFunc
from .throttling import BaseThrottler, LocalThrottler
from .cache import DEFAULT_CACHE_STRATEGY, BaseCacheStrategy


Expand All @@ -18,6 +19,7 @@ class Config:
timeout: httpx.Timeout
cache_strategy: BaseCacheStrategy
http_cache: bool
throttler: BaseThrottler
auto_retry: Optional[RetryDecisionFunc]
rest_api_validate_body: bool

Expand Down Expand Up @@ -72,6 +74,14 @@ def build_cache_strategy(
return cache_strategy or DEFAULT_CACHE_STRATEGY


def build_throttler(
throttler: Optional[BaseThrottler],
) -> BaseThrottler:
# https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#about-secondary-rate-limits
# > No more than 100 concurrent requests are allowed
return throttler or LocalThrottler(100)


def build_auto_retry(
auto_retry: Union[bool, RetryDecisionFunc] = True,
) -> Optional[RetryDecisionFunc]:
Expand All @@ -93,6 +103,7 @@ def get_config(
timeout: Optional[Union[float, httpx.Timeout]] = None,
cache_strategy: Optional[BaseCacheStrategy] = None,
http_cache: bool = True,
throttler: Optional[BaseThrottler] = None,
auto_retry: Union[bool, RetryDecisionFunc] = True,
rest_api_validate_body: bool = True,
) -> Config:
Expand All @@ -104,6 +115,7 @@ def get_config(
build_timeout(timeout),
build_cache_strategy(cache_strategy),
http_cache,
build_throttler(throttler),
build_auto_retry(auto_retry),
rest_api_validate_body,
)
78 changes: 45 additions & 33 deletions githubkit/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .utils import UNSET
from .response import Response
from .cache import BaseCacheStrategy
from .throttling import BaseThrottler
from .compat import to_jsonable_python
from .config import Config, get_config
from .auth import BaseAuthStrategy, TokenAuthStrategy, UnauthAuthStrategy
Expand Down Expand Up @@ -82,6 +83,7 @@ def __init__(
timeout: Optional[Union[float, httpx.Timeout]] = None,
cache_strategy: Optional[BaseCacheStrategy] = None,
http_cache: bool = True,
throttler: Optional[BaseThrottler] = None,
auto_retry: Union[bool, RetryDecisionFunc] = True,
rest_api_validate_body: bool = True,
): ...
Expand All @@ -100,6 +102,7 @@ def __init__(
timeout: Optional[Union[float, httpx.Timeout]] = None,
cache_strategy: Optional[BaseCacheStrategy] = None,
http_cache: bool = True,
throttler: Optional[BaseThrottler] = None,
auto_retry: Union[bool, RetryDecisionFunc] = True,
rest_api_validate_body: bool = True,
): ...
Expand All @@ -118,6 +121,7 @@ def __init__(
timeout: Optional[Union[float, httpx.Timeout]] = None,
cache_strategy: Optional[BaseCacheStrategy] = None,
http_cache: bool = True,
throttler: Optional[BaseThrottler] = None,
auto_retry: Union[bool, RetryDecisionFunc] = True,
rest_api_validate_body: bool = True,
): ...
Expand All @@ -135,6 +139,7 @@ def __init__(
timeout: Optional[Union[float, httpx.Timeout]] = None,
cache_strategy: Optional[BaseCacheStrategy] = None,
http_cache: bool = True,
throttler: Optional[BaseThrottler] = None,
auto_retry: Union[bool, RetryDecisionFunc] = True,
rest_api_validate_body: bool = True,
):
Expand All @@ -152,6 +157,7 @@ def __init__(
timeout=timeout,
cache_strategy=cache_strategy,
http_cache=http_cache,
throttler=throttler,
auto_retry=auto_retry,
rest_api_validate_body=rest_api_validate_body,
)
Expand Down Expand Up @@ -271,22 +277,24 @@ def _request(
cookies: Optional[CookieTypes] = None,
) -> httpx.Response:
with self.get_sync_client() as client:
try:
return client.request(
method,
url,
params=params,
content=content,
data=data,
files=files,
json=to_jsonable_python(json),
headers=headers,
cookies=cookies,
)
except httpx.TimeoutException as e:
raise RequestTimeout(e) from e
except Exception as e:
raise RequestError(e) from e
request = client.build_request(
method,
url,
params=params,
content=content,
data=data,
files=files,
json=to_jsonable_python(json),
headers=headers,
cookies=cookies,
)
with self.config.throttler.acquire(request):
try:
return client.send(request)
except httpx.TimeoutException as e:
raise RequestTimeout(e) from e
except Exception as e:
raise RequestError(e) from e

# async request
async def _arequest(
Expand All @@ -302,23 +310,27 @@ async def _arequest(
headers: Optional[HeaderTypes] = None,
cookies: Optional[CookieTypes] = None,
) -> httpx.Response:
async with self.get_async_client() as client:
try:
return await client.request(
method,
url,
params=params,
content=content,
data=data,
files=files,
json=to_jsonable_python(json),
headers=headers,
cookies=cookies,
)
except httpx.TimeoutException as e:
raise RequestTimeout(e) from e
except Exception as e:
raise RequestError(e) from e
async with (
self.get_async_client() as client,
):
request = client.build_request(
method,
url,
params=params,
content=content,
data=data,
files=files,
json=to_jsonable_python(json),
headers=headers,
cookies=cookies,
)
async with self.config.throttler.async_acquire(request):
try:
return await client.send(request)
except httpx.TimeoutException as e:
raise RequestTimeout(e) from e
except Exception as e:
raise RequestError(e) from e

# check and parse response
@overload
Expand Down
4 changes: 4 additions & 0 deletions githubkit/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from .config import Config
from .cache import BaseCacheStrategy
from .throttling import BaseThrottler
from .auth import TokenAuthStrategy, UnauthAuthStrategy


Expand Down Expand Up @@ -75,6 +76,7 @@ def __init__(
timeout: Optional[Union[float, httpx.Timeout]] = None,
cache_strategy: Optional["BaseCacheStrategy"] = None,
http_cache: bool = True,
throttler: Optional["BaseThrottler"] = None,
auto_retry: Union[bool, RetryDecisionFunc] = True,
rest_api_validate_body: bool = True,
): ...
Expand All @@ -93,6 +95,7 @@ def __init__(
timeout: Optional[Union[float, httpx.Timeout]] = None,
cache_strategy: Optional["BaseCacheStrategy"] = None,
http_cache: bool = True,
throttler: Optional["BaseThrottler"] = None,
auto_retry: Union[bool, RetryDecisionFunc] = True,
rest_api_validate_body: bool = True,
): ...
Expand All @@ -111,6 +114,7 @@ def __init__(
timeout: Optional[Union[float, httpx.Timeout]] = None,
cache_strategy: Optional["BaseCacheStrategy"] = None,
http_cache: bool = True,
throttler: Optional["BaseThrottler"] = None,
auto_retry: Union[bool, RetryDecisionFunc] = True,
rest_api_validate_body: bool = True,
): ...
Expand Down
57 changes: 57 additions & 0 deletions githubkit/throttling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import abc
import threading
from typing import Any, Optional
from typing_extensions import override
from collections.abc import Generator, AsyncGenerator
from contextlib import contextmanager, asynccontextmanager

import anyio
import httpx


class BaseThrottler(abc.ABC):
"""Throttle the number of concurrent requests to avoid hitting rate limits.

See also:
- https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api#avoid-concurrent-requests
- https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#about-secondary-rate-limits

TODO: Implement the pause between mutative requests.
"""

@abc.abstractmethod
@contextmanager
def acquire(self, request: httpx.Request) -> Generator[None, Any, Any]:
raise NotImplementedError
yield

@abc.abstractmethod
@asynccontextmanager
async def async_acquire(self, request: httpx.Request) -> AsyncGenerator[None, Any]:
raise NotImplementedError
yield


class LocalThrottler(BaseThrottler):
def __init__(self, max_concurrency: int) -> None:
self.max_concurrency = max_concurrency
self.semaphore = threading.Semaphore(max_concurrency)
self._async_semaphore: Optional[anyio.Semaphore] = None

@property
def async_semaphore(self) -> anyio.Semaphore:
if self._async_semaphore is None:
self._async_semaphore = anyio.Semaphore(self.max_concurrency)
return self._async_semaphore

@override
@contextmanager
def acquire(self, request: httpx.Request) -> Generator[None, Any, Any]:
with self.semaphore:
yield

@override
@asynccontextmanager
async def async_acquire(self, request: httpx.Request) -> AsyncGenerator[None, Any]:
async with self.async_semaphore:
yield
8 changes: 4 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ include = ["githubkit/py.typed"]

[tool.poetry.dependencies]
python = "^3.9"
anyio = ">=3.6.1, <5.0.0"
httpx = ">=0.23.0, <1.0.0"
typing-extensions = "^4.6.0"
hishel = ">=0.0.21, <=0.2.0"
pydantic = ">=1.9.1, <3.0.0, !=2.5.0, !=2.5.1"
anyio = { version = ">=3.6.1, <5.0.0", optional = true }
PyJWT = { version = "^2.4.0", extras = ["crypto"], optional = true }

[tool.poetry.group.dev.dependencies]
Expand All @@ -44,9 +44,9 @@ mkdocs-git-revision-date-localized-plugin = "^1.2.9"
[tool.poetry.extras]
jwt = ["PyJWT"]
auth-app = ["PyJWT"]
auth-oauth-device = ["anyio"]
auth = ["PyJWT", "anyio"]
all = ["PyJWT", "anyio"]
auth-oauth-device = [] # backward compatibility
auth = ["PyJWT"]
all = ["PyJWT"]

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