googleapis-without-429 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- googleapis_without_429-0.1.0/.github/workflows/ci.yml +66 -0
- googleapis_without_429-0.1.0/.github/workflows/release.yml +101 -0
- googleapis_without_429-0.1.0/.gitignore +24 -0
- googleapis_without_429-0.1.0/.pre-commit-config.yaml +30 -0
- googleapis_without_429-0.1.0/CHANGELOG.md +41 -0
- googleapis_without_429-0.1.0/LICENSE +21 -0
- googleapis_without_429-0.1.0/Makefile +32 -0
- googleapis_without_429-0.1.0/PKG-INFO +250 -0
- googleapis_without_429-0.1.0/README.md +222 -0
- googleapis_without_429-0.1.0/pyproject.toml +189 -0
- googleapis_without_429-0.1.0/scripts/check_sdist.py +73 -0
- googleapis_without_429-0.1.0/src/googleapis_without_429/__init__.py +23 -0
- googleapis_without_429-0.1.0/src/googleapis_without_429/backoff.py +88 -0
- googleapis_without_429-0.1.0/src/googleapis_without_429/core.py +139 -0
- googleapis_without_429-0.1.0/src/googleapis_without_429/limiter.py +143 -0
- googleapis_without_429-0.1.0/src/googleapis_without_429/profiles.py +252 -0
- googleapis_without_429-0.1.0/src/googleapis_without_429/py.typed +0 -0
- googleapis_without_429-0.1.0/src/googleapis_without_429/session.py +123 -0
- googleapis_without_429-0.1.0/tests/__init__.py +0 -0
- googleapis_without_429-0.1.0/tests/conftest.py +34 -0
- googleapis_without_429-0.1.0/tests/test_backoff.py +87 -0
- googleapis_without_429-0.1.0/tests/test_core.py +186 -0
- googleapis_without_429-0.1.0/tests/test_gspread_integration.py +180 -0
- googleapis_without_429-0.1.0/tests/test_limiter.py +159 -0
- googleapis_without_429-0.1.0/tests/test_profiles.py +272 -0
- googleapis_without_429-0.1.0/tests/test_readme.py +149 -0
- googleapis_without_429-0.1.0/tests/test_session.py +286 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
# Callable from the release workflow, so a tag runs the very same checks
|
|
8
|
+
# rather than a second copy of them that can drift.
|
|
9
|
+
workflow_call:
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
13
|
+
cancel-in-progress: true
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
test:
|
|
17
|
+
name: tests (python ${{ matrix.python-version }})
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
strategy:
|
|
20
|
+
fail-fast: false
|
|
21
|
+
matrix:
|
|
22
|
+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
23
|
+
env:
|
|
24
|
+
UV_PYTHON: ${{ matrix.python-version }}
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v7
|
|
27
|
+
# Pinned to the full version: this action publishes no major-tag alias.
|
|
28
|
+
- uses: astral-sh/setup-uv@v10.0.1
|
|
29
|
+
with:
|
|
30
|
+
enable-cache: true
|
|
31
|
+
- run: uv sync --locked
|
|
32
|
+
- run: make test
|
|
33
|
+
|
|
34
|
+
quality:
|
|
35
|
+
name: lint, format and types
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
steps:
|
|
38
|
+
- uses: actions/checkout@v7
|
|
39
|
+
# Pinned to the full version: this action publishes no major-tag alias.
|
|
40
|
+
- uses: astral-sh/setup-uv@v10.0.1
|
|
41
|
+
with:
|
|
42
|
+
enable-cache: true
|
|
43
|
+
- run: uv sync --locked
|
|
44
|
+
- run: make lint
|
|
45
|
+
- run: make check-format
|
|
46
|
+
- run: make typecheck
|
|
47
|
+
|
|
48
|
+
build:
|
|
49
|
+
name: package builds and installs
|
|
50
|
+
runs-on: ubuntu-latest
|
|
51
|
+
steps:
|
|
52
|
+
- uses: actions/checkout@v7
|
|
53
|
+
# Pinned to the full version: this action publishes no major-tag alias.
|
|
54
|
+
- uses: astral-sh/setup-uv@v10.0.1
|
|
55
|
+
with:
|
|
56
|
+
enable-cache: true
|
|
57
|
+
- run: uv build
|
|
58
|
+
# Nothing in git stops a file from reaching an artefact: internal notes
|
|
59
|
+
# are excluded through .git/info/exclude, which no build backend reads.
|
|
60
|
+
- run: uv run --no-sync python scripts/check_sdist.py
|
|
61
|
+
# Installing the built wheel into a clean environment is the only check
|
|
62
|
+
# that catches a file missing from the package but present on disk.
|
|
63
|
+
- name: install the built wheel into a clean environment
|
|
64
|
+
run: |
|
|
65
|
+
uv run --isolated --no-project --with dist/*.whl \
|
|
66
|
+
python -c "import googleapis_without_429 as m; print(m.__version__)"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Publishing uses PyPI Trusted Publishing: GitHub mints a short-lived OIDC
|
|
4
|
+
# token for this specific workflow in this specific repository, and PyPI
|
|
5
|
+
# verifies it. No API token is stored anywhere, so there is none to leak.
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
tags: ["v*"]
|
|
9
|
+
workflow_dispatch:
|
|
10
|
+
inputs:
|
|
11
|
+
repository:
|
|
12
|
+
description: Index to publish to
|
|
13
|
+
type: choice
|
|
14
|
+
default: testpypi
|
|
15
|
+
options: [testpypi, pypi]
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
checks:
|
|
19
|
+
uses: ./.github/workflows/ci.yml
|
|
20
|
+
|
|
21
|
+
build:
|
|
22
|
+
needs: checks
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v7
|
|
26
|
+
- uses: astral-sh/setup-uv@v10.0.1
|
|
27
|
+
with:
|
|
28
|
+
enable-cache: true
|
|
29
|
+
|
|
30
|
+
# A tag that disagrees with the packaged version publishes something
|
|
31
|
+
# nobody can find again. Cheap to check, expensive to discover later.
|
|
32
|
+
- name: Tag must match the packaged version
|
|
33
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
34
|
+
run: |
|
|
35
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
36
|
+
version=$(python3 -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
|
|
37
|
+
if [ "$tag" != "$version" ]; then
|
|
38
|
+
echo "::error::tag v$tag does not match project version $version"
|
|
39
|
+
exit 1
|
|
40
|
+
fi
|
|
41
|
+
echo "tag and version agree: $version"
|
|
42
|
+
|
|
43
|
+
- run: uv build
|
|
44
|
+
- run: uv run --no-sync python scripts/check_sdist.py
|
|
45
|
+
- run: uv run --isolated --no-project --with twine twine check dist/*
|
|
46
|
+
|
|
47
|
+
- uses: actions/upload-artifact@v7
|
|
48
|
+
with:
|
|
49
|
+
name: distributions
|
|
50
|
+
path: dist/
|
|
51
|
+
|
|
52
|
+
publish-testpypi:
|
|
53
|
+
if: github.event_name == 'workflow_dispatch' && inputs.repository == 'testpypi'
|
|
54
|
+
needs: build
|
|
55
|
+
runs-on: ubuntu-latest
|
|
56
|
+
environment:
|
|
57
|
+
name: testpypi
|
|
58
|
+
url: https://test.pypi.org/p/googleapis-without-429
|
|
59
|
+
permissions:
|
|
60
|
+
id-token: write
|
|
61
|
+
steps:
|
|
62
|
+
- uses: actions/download-artifact@v8
|
|
63
|
+
with:
|
|
64
|
+
name: distributions
|
|
65
|
+
path: dist/
|
|
66
|
+
- uses: pypa/gh-action-pypi-publish@v1.14.2
|
|
67
|
+
with:
|
|
68
|
+
repository-url: https://test.pypi.org/legacy/
|
|
69
|
+
|
|
70
|
+
publish-pypi:
|
|
71
|
+
if: startsWith(github.ref, 'refs/tags/v') || inputs.repository == 'pypi'
|
|
72
|
+
needs: build
|
|
73
|
+
runs-on: ubuntu-latest
|
|
74
|
+
environment:
|
|
75
|
+
name: pypi
|
|
76
|
+
url: https://pypi.org/p/googleapis-without-429
|
|
77
|
+
permissions:
|
|
78
|
+
id-token: write
|
|
79
|
+
steps:
|
|
80
|
+
- uses: actions/download-artifact@v8
|
|
81
|
+
with:
|
|
82
|
+
name: distributions
|
|
83
|
+
path: dist/
|
|
84
|
+
- uses: pypa/gh-action-pypi-publish@v1.14.2
|
|
85
|
+
|
|
86
|
+
github-release:
|
|
87
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
88
|
+
needs: publish-pypi
|
|
89
|
+
runs-on: ubuntu-latest
|
|
90
|
+
permissions:
|
|
91
|
+
contents: write
|
|
92
|
+
steps:
|
|
93
|
+
- uses: actions/checkout@v7
|
|
94
|
+
- uses: actions/download-artifact@v8
|
|
95
|
+
with:
|
|
96
|
+
name: distributions
|
|
97
|
+
path: dist/
|
|
98
|
+
- uses: softprops/action-gh-release@v3
|
|
99
|
+
with:
|
|
100
|
+
files: dist/*
|
|
101
|
+
generate_release_notes: true
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Build output
|
|
2
|
+
build/
|
|
3
|
+
dist/
|
|
4
|
+
*.egg-info/
|
|
5
|
+
|
|
6
|
+
# Bytecode
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.py[cod]
|
|
9
|
+
|
|
10
|
+
# Virtual environments
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
|
|
14
|
+
# Tool caches and coverage
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
.coverage
|
|
19
|
+
.coverage.*
|
|
20
|
+
htmlcov/
|
|
21
|
+
coverage.xml
|
|
22
|
+
|
|
23
|
+
# macOS writes these into every directory it opens
|
|
24
|
+
.DS_Store
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Runs on commit. The same checks run again in CI, because a hook can be
|
|
2
|
+
# skipped with --no-verify and a pipeline cannot.
|
|
3
|
+
repos:
|
|
4
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
5
|
+
rev: v6.0.0
|
|
6
|
+
hooks:
|
|
7
|
+
- id: trailing-whitespace
|
|
8
|
+
- id: end-of-file-fixer
|
|
9
|
+
- id: check-yaml
|
|
10
|
+
- id: check-toml
|
|
11
|
+
- id: check-merge-conflict
|
|
12
|
+
- id: check-added-large-files
|
|
13
|
+
|
|
14
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
15
|
+
rev: v0.16.6
|
|
16
|
+
hooks:
|
|
17
|
+
- id: ruff-check
|
|
18
|
+
args: [--fix]
|
|
19
|
+
- id: ruff-format
|
|
20
|
+
|
|
21
|
+
- repo: local
|
|
22
|
+
hooks:
|
|
23
|
+
- id: mypy
|
|
24
|
+
name: mypy
|
|
25
|
+
# --no-sync matters: a plain `uv run` reconciles uv.lock with
|
|
26
|
+
# pyproject.toml, and a hook that rewrites a file mid-commit fails.
|
|
27
|
+
entry: uv run --no-sync mypy
|
|
28
|
+
language: system
|
|
29
|
+
pass_filenames: false
|
|
30
|
+
types: [python]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
Nothing yet.
|
|
11
|
+
|
|
12
|
+
## [0.1.0] - 2026-09-09
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `WeightedSlidingWindow`: thread-safe core limiter that counts cost rather
|
|
17
|
+
than calls, so one implementation covers both request-counted APIs (Sheets)
|
|
18
|
+
and quota-unit APIs (Gmail).
|
|
19
|
+
- `ApiProfile` and the `SHEETS` profile: per-API data describing the host, its
|
|
20
|
+
separate read and write quotas, and how a call maps to one of them. Limits
|
|
21
|
+
are overridable with `SHEETS.with_limits(read=300, write=300)`, since real
|
|
22
|
+
quotas depend on the project and Google revises them.
|
|
23
|
+
- `RateLimitedSession`: an `AuthorizedSession` that paces itself against a
|
|
24
|
+
profile's quotas and retries a 429 with equal-jitter backoff, honouring
|
|
25
|
+
`Retry-After` when the server sends one. Requests to hosts without a profile
|
|
26
|
+
pass through untouched, so token refreshes do not consume the API's quota.
|
|
27
|
+
Drop it into any client that accepts a session, such as gspread.
|
|
28
|
+
- `DRIVE` profile, covering the half of gspread that is not Sheets: creating,
|
|
29
|
+
deleting, sharing and finding a spreadsheet by title all go to the Drive API.
|
|
30
|
+
Drive meters weighted quota units in one shared bucket rather than counting
|
|
31
|
+
requests in two, so a listing costs twenty times a single item read.
|
|
32
|
+
- Profiles can claim path prefixes, since `www.googleapis.com` serves several
|
|
33
|
+
APIs and a host alone no longer identifies which quota applies.
|
|
34
|
+
- `QuotaLimiter`: the quota buckets on their own, for code that does not go
|
|
35
|
+
through a `requests` session. `limiter.limit(SHEETS, "write")` works both as
|
|
36
|
+
a context manager and as a decorator, and `limiter.bucket(...)` exposes the
|
|
37
|
+
underlying window for anything this library does not model. A session's own
|
|
38
|
+
buckets are reachable through `session.limiter`.
|
|
39
|
+
|
|
40
|
+
[Unreleased]: https://github.com/pavlosambur/googleapis-without-429/compare/v0.1.0...HEAD
|
|
41
|
+
[0.1.0]: https://github.com/pavlosambur/googleapis-without-429/releases/tag/v0.1.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pavlo Sambur
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# One definition of what "checked" means. CI calls these same targets, so a
|
|
2
|
+
# green local run and a green pipeline cannot drift apart.
|
|
3
|
+
.DEFAULT_GOAL := help
|
|
4
|
+
.PHONY: help install lint format check-format typecheck test package check
|
|
5
|
+
|
|
6
|
+
help: ## Show the available targets
|
|
7
|
+
@grep -E '^[a-z-]+:.*?## ' $(MAKEFILE_LIST) \
|
|
8
|
+
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
|
|
9
|
+
|
|
10
|
+
install: ## Install the project and its development dependencies
|
|
11
|
+
uv sync
|
|
12
|
+
|
|
13
|
+
lint: ## Report lint violations
|
|
14
|
+
uv run ruff check .
|
|
15
|
+
|
|
16
|
+
format: ## Reformat the code in place
|
|
17
|
+
uv run ruff format .
|
|
18
|
+
|
|
19
|
+
check-format: ## Verify formatting without changing anything
|
|
20
|
+
uv run ruff format --check .
|
|
21
|
+
|
|
22
|
+
typecheck: ## Run the type checker
|
|
23
|
+
uv run mypy
|
|
24
|
+
|
|
25
|
+
test: ## Run the test suite with coverage
|
|
26
|
+
uv run pytest --cov
|
|
27
|
+
|
|
28
|
+
package: ## Build the distributions and check what they contain
|
|
29
|
+
uv build
|
|
30
|
+
uv run --no-sync python scripts/check_sdist.py
|
|
31
|
+
|
|
32
|
+
check: lint check-format typecheck test package ## Everything the pipeline runs
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: googleapis-without-429
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drop-in transport for Google API clients that stays inside the quota instead of recovering from 429.
|
|
5
|
+
Project-URL: Homepage, https://github.com/pavlosambur/googleapis-without-429
|
|
6
|
+
Project-URL: Repository, https://github.com/pavlosambur/googleapis-without-429
|
|
7
|
+
Project-URL: Issues, https://github.com/pavlosambur/googleapis-without-429/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/pavlosambur/googleapis-without-429/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Pavlo Sambur <samburp@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: 429,google-api,google-sheets,gspread,quota,rate-limiting
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: google-auth>=2.0
|
|
26
|
+
Requires-Dist: requests>=2.31
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# googleapis-without-429
|
|
30
|
+
|
|
31
|
+
[](https://github.com/pavlosambur/googleapis-without-429/actions/workflows/ci.yml)
|
|
32
|
+
[](https://github.com/pavlosambur/googleapis-without-429)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
|
|
35
|
+
Stay inside Google API quotas instead of recovering from `429 Too many
|
|
36
|
+
requests`. One argument, and the rest of your code is unchanged.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import gspread
|
|
40
|
+
from googleapis_without_429 import RateLimitedSession
|
|
41
|
+
|
|
42
|
+
session = RateLimitedSession(credentials) # <- the only change
|
|
43
|
+
gc = gspread.authorize(credentials, session=session)
|
|
44
|
+
|
|
45
|
+
sheet = gc.open("My Sheet").sheet1
|
|
46
|
+
for row in rows:
|
|
47
|
+
sheet.append_row(row) # waits when the quota is spent, then continues
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
No decorators to add, no calls to rewrite, no `sleep()` sprinkled through the
|
|
51
|
+
loop. The session knows what Google's quotas are and paces itself.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install googleapis-without-429
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Requires Python 3.10 or newer.
|
|
60
|
+
|
|
61
|
+
## The problem
|
|
62
|
+
|
|
63
|
+
Google's per-minute quotas are small. Sheets allows **60 reads and 60 writes
|
|
64
|
+
per minute per user** — a loop that appends rows hits that in a minute of
|
|
65
|
+
ordinary work, and the script dies partway through with half the data written.
|
|
66
|
+
|
|
67
|
+
The official advice is exponential backoff, and every retry library implements
|
|
68
|
+
it. But backoff is a reaction *after* the failure: it recovers, it does not
|
|
69
|
+
prevent. The better first move is not to exceed the quota at all, and to keep
|
|
70
|
+
retries as the second line of defence.
|
|
71
|
+
|
|
72
|
+
That is what this does. It tracks what you have spent against a sliding window
|
|
73
|
+
and blocks the call that would go over, instead of letting Google reject it.
|
|
74
|
+
|
|
75
|
+
## What is covered
|
|
76
|
+
|
|
77
|
+
| API | Quota | How it is metered |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| Sheets | 60 reads + 60 writes per minute | separate buckets; every call costs 1, batches included |
|
|
80
|
+
| Drive | 325,000 quota units per minute | one shared bucket; a call costs 5 to 200 units |
|
|
81
|
+
|
|
82
|
+
Together these cover [gspread](https://github.com/burnash/gspread) completely —
|
|
83
|
+
which needs both, since Sheets moves the cell data while Drive owns the file:
|
|
84
|
+
|
|
85
|
+
| gspread call | Goes to |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `open_by_key`, `open_by_url` | Sheets |
|
|
88
|
+
| `worksheet.get`, `get_all_values`, `batch_get` | Sheets |
|
|
89
|
+
| `append_row`, `update`, `clear`, `batch_update` | Sheets |
|
|
90
|
+
| `open("title")`, `openall`, `list_spreadsheet_files` | Drive, then Sheets |
|
|
91
|
+
| `create`, `copy`, `del_spreadsheet`, `share` | Drive |
|
|
92
|
+
|
|
93
|
+
Nothing else is covered yet: Gmail, Calendar and Docs have no profile, and
|
|
94
|
+
`google-api-python-client` uses a different transport. Both are on the roadmap.
|
|
95
|
+
A request to any host without a profile passes through untouched — including
|
|
96
|
+
the token refresh your credentials perform, which must not eat the quota of the
|
|
97
|
+
API you are actually calling.
|
|
98
|
+
|
|
99
|
+
## This does not remove the need for retries
|
|
100
|
+
|
|
101
|
+
It reduces 429s. It does not eliminate them, and any library claiming otherwise
|
|
102
|
+
is overselling.
|
|
103
|
+
|
|
104
|
+
The reason is that the two sides count differently. This library slides a
|
|
105
|
+
window over the timestamps of *your* calls. Google meters *fixed* windows whose
|
|
106
|
+
boundaries you cannot see. So 60 calls that look perfectly spaced from here can
|
|
107
|
+
land as 30 in the tail of one of Google's minutes and 30 in the head of the
|
|
108
|
+
next — and the second batch is over the limit even though our counter says
|
|
109
|
+
there is room.
|
|
110
|
+
|
|
111
|
+
Being strict about our own window makes us conservative, never reckless: we may
|
|
112
|
+
allow fewer calls than Google would, never more. But the boundary mismatch is
|
|
113
|
+
real, so a retry on 429 is built in and on by default:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
RateLimitedSession(
|
|
117
|
+
credentials,
|
|
118
|
+
max_attempts=5, # total tries per request, including the first
|
|
119
|
+
backoff_base=1.0, # ceiling for the first retry delay, in seconds
|
|
120
|
+
backoff_cap=60.0, # the ceiling stops doubling here
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Delays use equal jitter: half the ceiling is always waited and the rest is
|
|
125
|
+
randomised. The guaranteed half matters — a 429 means the window has not
|
|
126
|
+
reopened yet, so a delay that comes out near zero only buys another 429. A
|
|
127
|
+
`Retry-After` header, if the server sends one, wins over the computed delay.
|
|
128
|
+
|
|
129
|
+
## Adjusting the limits
|
|
130
|
+
|
|
131
|
+
The shipped numbers are Google's documented defaults, and defaults go stale.
|
|
132
|
+
Real quotas depend on the project, on when it was created, and Google revises
|
|
133
|
+
them — Drive's changed on 1 May 2026, and projects already using the API kept
|
|
134
|
+
the previous ones. So overriding is a first-class operation:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from googleapis_without_429 import DRIVE, SHEETS, RateLimitedSession
|
|
138
|
+
|
|
139
|
+
session = RateLimitedSession(
|
|
140
|
+
credentials,
|
|
141
|
+
[
|
|
142
|
+
SHEETS.with_limits(read=300, write=300), # the per-project ceiling
|
|
143
|
+
DRIVE.with_limits(units=12_000), # an older project
|
|
144
|
+
],
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The defaults are the **per-user** quotas, which is what a single script runs
|
|
149
|
+
into. Raise them to the per-project ceiling only if the job really is the only
|
|
150
|
+
thing using that project. A misspelled bucket name raises rather than being
|
|
151
|
+
quietly ignored, so a typo cannot leave you believing a limit was raised.
|
|
152
|
+
|
|
153
|
+
Check what your project actually has in the Cloud Console under
|
|
154
|
+
**APIs & Services → Quotas**; it can differ from the documentation.
|
|
155
|
+
|
|
156
|
+
## Without a session
|
|
157
|
+
|
|
158
|
+
If the calls are not made through a `requests` session — a hand-rolled client,
|
|
159
|
+
a worker, an API this library has no adapter for — use the limiter directly. It
|
|
160
|
+
is both a context manager and a decorator:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from googleapis_without_429 import SHEETS, QuotaLimiter
|
|
164
|
+
|
|
165
|
+
limiter = QuotaLimiter([SHEETS])
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@limiter.limit(SHEETS, "write")
|
|
169
|
+
def push_batch(rows): ...
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
with limiter.limit(SHEETS, "read"):
|
|
173
|
+
...
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
And the raw window underneath, when nothing above fits:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
limiter.bucket(SHEETS, "write").acquire(cost=1)
|
|
180
|
+
limiter.bucket(SHEETS, "write").used # what is currently counted
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
A session exposes its own limiter the same way, so you can pace a call it does
|
|
184
|
+
not make itself:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
session.limiter.bucket(SHEETS, "read").acquire()
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Adding an API
|
|
191
|
+
|
|
192
|
+
A profile is data, not code: a host, a map of buckets to limits, and a function
|
|
193
|
+
that says which bucket a call belongs to and what it costs.
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
from googleapis_without_429 import ApiProfile, QuotaLimiter
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def resolve_docs(http_method, path, query):
|
|
200
|
+
return ("read" if http_method == "GET" else "write"), 1
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
DOCS = ApiProfile(
|
|
204
|
+
name="docs",
|
|
205
|
+
host="docs.googleapis.com",
|
|
206
|
+
limits={"read": 300, "write": 60},
|
|
207
|
+
resolve=resolve_docs,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
limiter = QuotaLimiter([DOCS])
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Two things worth knowing before writing one:
|
|
214
|
+
|
|
215
|
+
- **The HTTP method is not the whole story.** Sheets sends several reads as
|
|
216
|
+
POST (`:getByDataFilter`, `:batchGetByDataFilter`, `developerMetadata:search`)
|
|
217
|
+
because they carry a request body. Charging those to the write bucket burns
|
|
218
|
+
one of only 60 writes a minute. The published
|
|
219
|
+
[discovery document](https://developers.google.com/discovery/v1/reference)
|
|
220
|
+
for an API lists every method with its real HTTP verb and path.
|
|
221
|
+
- **A host may serve several APIs.** Drive lives on `www.googleapis.com`
|
|
222
|
+
alongside others, so its profile claims `path_prefixes=("/drive/",
|
|
223
|
+
"/upload/drive/")`. Leave that empty only when the host belongs to one API.
|
|
224
|
+
|
|
225
|
+
Profiles for other Google APIs are very welcome as pull requests — that is the
|
|
226
|
+
cheapest way for this library to grow, and it needs no changes to the core.
|
|
227
|
+
|
|
228
|
+
## Development
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
uv sync # install the project and its dev dependencies
|
|
232
|
+
make check # everything CI runs: lint, format, types, tests
|
|
233
|
+
uv run pre-commit install # optional: fast checks on every commit
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Individual steps: `make lint`, `make check-format`, `make typecheck`,
|
|
237
|
+
`make test`. CI calls the same targets, so a green local run means a green
|
|
238
|
+
pipeline. The test suite needs no credentials and makes no network calls.
|
|
239
|
+
|
|
240
|
+
## Roadmap
|
|
241
|
+
|
|
242
|
+
- An adapter for `google-api-python-client`, which uses an httplib2-style
|
|
243
|
+
transport rather than a `requests` session
|
|
244
|
+
- A Gmail profile — its quota units range from 2 to 100 per call, which is what
|
|
245
|
+
the weighted core was built for
|
|
246
|
+
- Async support
|
|
247
|
+
|
|
248
|
+
## License
|
|
249
|
+
|
|
250
|
+
MIT
|