├── .github ├── dependabot.yaml ├── set_version.py └── workflows │ ├── audit.yaml │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── bench.py ├── build.rs ├── coverage-report.sh ├── dev-requirements.txt ├── docker-compose.yml ├── docs ├── logo.svg ├── semaphore.png ├── semaphore.svg ├── token_bucket.png └── token_bucket.svg ├── pyproject.toml ├── rustfmt.toml ├── scripts ├── semaphore.lua └── token_bucket.lua ├── self_limiters.pyi ├── setup.cfg ├── src ├── errors.rs ├── generated.rs ├── lib.rs ├── semaphore.rs ├── token_bucket.rs └── utils.rs └── tests ├── __init__.py ├── conftest.py ├── test_errors.py ├── test_semaphore.py └── test_token_bucket.py /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: / 6 | target-branch: main 7 | schedule: 8 | interval: daily 9 | reviewers: 10 | - sondrelg 11 | -------------------------------------------------------------------------------- /.github/set_version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Stolen from the pydantic-core repo 3 | import os 4 | import re 5 | import sys 6 | from pathlib import Path 7 | 8 | 9 | def main(cargo_path_env_var='CARGO_PATH', version_env_vars=('VERSION', 'GITHUB_REF')) -> int: 10 | cargo_path = Path(os.getenv(cargo_path_env_var, 'Cargo.toml')) 11 | if not cargo_path.is_file(): 12 | print(f'✖ path "{cargo_path}" does not exist') 13 | return 1 14 | 15 | version = None 16 | for var in version_env_vars: 17 | if version_ref := os.getenv(var): 18 | version = re.sub('^refs/tags/v*', '', version_ref.lower()) 19 | break 20 | if not version: 21 | print(f'✖ "{version_env_vars}" env variables not found') 22 | return 1 23 | 24 | # convert from python pre-release version to rust pre-release version 25 | # this is the reverse of what's done in lib.rs::_rust_notify 26 | version = version.replace('a', '-alpha').replace('b', '-beta') 27 | print(f'writing version "{version}", to {cargo_path}') 28 | 29 | version_regex = re.compile('^version ?= ?".*"', re.M) 30 | cargo_content = cargo_path.read_text() 31 | if not version_regex.search(cargo_content): 32 | print(f'✖ {version_regex!r} not found in {cargo_path}') 33 | return 1 34 | 35 | new_content = version_regex.sub(f'version = "{version}"', cargo_content) 36 | cargo_path.write_text(new_content) 37 | return 0 38 | 39 | 40 | if __name__ == '__main__': 41 | sys.exit(main()) 42 | -------------------------------------------------------------------------------- /.github/workflows/audit.yaml: -------------------------------------------------------------------------------- 1 | name: security audit 2 | on: 3 | push: 4 | paths: 5 | - '**/Cargo.toml' 6 | - '**/Cargo.lock' 7 | jobs: 8 | security_audit: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions-rs/audit-check@v1 13 | with: 14 | token: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # Stolen and adapted from https://github.com/pydantic/pydantic-core/blob/main/.github/workflows/ci.yml 2 | name: release 3 | 4 | on: 5 | release: 6 | types: [published, edited] 7 | 8 | jobs: 9 | build: 10 | name: build on ${{ matrix.platform || matrix.os }} (${{ matrix.target }} - ${{ matrix.manylinux || 'auto' }}) 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | os: [ubuntu, macos, windows] 15 | target: [x86_64, aarch64] 16 | manylinux: [auto] 17 | include: 18 | - os: ubuntu 19 | platform: linux 20 | - os: windows 21 | ls: dir 22 | interpreter: 3.7 3.8 3.9 3.10 23 | - os: windows 24 | ls: dir 25 | target: i686 26 | python-architecture: x86 27 | interpreter: 3.7 3.8 3.9 3.10 28 | - os: macos 29 | target: aarch64 30 | interpreter: 3.7 3.8 3.9 3.10 31 | - os: ubuntu 32 | platform: linux 33 | target: i686 34 | # GCC 4.8.5 in manylinux2014 container doesn't support c11 atomic 35 | # we use manylinux_2_24 container for aarch64 and armv7 targets instead, 36 | - os: ubuntu 37 | platform: linux 38 | target: aarch64 39 | container: messense/manylinux_2_24-cross:aarch64 40 | - os: ubuntu 41 | platform: linux 42 | target: armv7 43 | container: messense/manylinux_2_24-cross:armv7 44 | interpreter: 3.7 3.8 3.9 3.10 45 | # musllinux 46 | - os: ubuntu 47 | platform: linux 48 | target: x86_64 49 | manylinux: musllinux_1_1 50 | - os: ubuntu 51 | platform: linux 52 | target: aarch64 53 | manylinux: musllinux_1_1 54 | exclude: 55 | # Windows on arm64 only supports Python 3.11+ 56 | - os: windows 57 | target: aarch64 58 | 59 | runs-on: ${{ matrix.os }}-latest 60 | steps: 61 | - uses: actions/checkout@v4 62 | - uses: actions/setup-python@v5 63 | with: 64 | python-version: '3.10' 65 | architecture: ${{ matrix.python-architecture || 'x64' }} 66 | 67 | - name: set package version 68 | run: python .github/set_version.py 69 | 70 | - name: sync Cargo.lock 71 | run: cargo update -p self-limiters 72 | if: "startsWith(github.ref, 'refs/tags/')" 73 | 74 | - run: pip install -U twine 'black>=22.3.0,<23' typing_extensions 75 | 76 | - name: build wheels 77 | uses: messense/maturin-action@v1 78 | with: 79 | target: ${{ matrix.target }} 80 | manylinux: ${{ matrix.manylinux || 'auto' }} 81 | container: ${{ matrix.container }} 82 | args: --release --out dist --interpreter ${{ matrix.interpreter || '3.7 3.8 3.9 3.10' }} 83 | rust-toolchain: stable 84 | 85 | - run: ${{ matrix.ls || 'ls -lh' }} dist/ 86 | - run: twine check dist/* 87 | - uses: actions/upload-artifact@v4 88 | with: 89 | name: pypi_files 90 | path: dist 91 | 92 | release: 93 | needs: [build] 94 | runs-on: ubuntu-latest 95 | steps: 96 | - uses: actions/checkout@v4 97 | - uses: actions/setup-python@v5 98 | with: 99 | python-version: '3.10' 100 | - run: pip install -U twine 101 | - name: get dist artifacts 102 | uses: actions/download-artifact@v4 103 | with: 104 | name: pypi_files 105 | path: dist 106 | - run: twine check dist/* 107 | - name: upload to pypi 108 | run: twine upload dist/* 109 | env: 110 | TWINE_USERNAME: __token__ 111 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 112 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | pre_commit: 11 | name: "pre-commit" 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-python@v5 16 | with: 17 | python-version: "3.11" 18 | - run: pip install pre-commit 19 | - uses: actions/cache@v3 20 | id: pre-commit-cache 21 | with: 22 | path: ~/.cache/pre-commit 23 | key: key-0 24 | - run: pre-commit run --all-files 25 | 26 | python_test: 27 | runs-on: ubuntu-latest 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | python-version: [ "3.9", "3.10", "3.11.1" ] 32 | services: 33 | redis: 34 | image: redis:6.2.5-alpine 35 | options: >- 36 | --health-cmd "redis-cli ping" 37 | --health-interval 10s 38 | --health-timeout 5s 39 | --health-retries 5 40 | ports: 41 | - '127.0.0.1:6389:6379' 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: actions-rs/toolchain@v1 45 | with: 46 | toolchain: stable 47 | override: true 48 | profile: minimal 49 | components: llvm-tools-preview 50 | - uses: taiki-e/install-action@cargo-llvm-cov 51 | - uses: actions/setup-python@v5 52 | id: python-setup 53 | with: 54 | python-version: "${{ matrix.python-version }}" 55 | - uses: actions/cache@v3 56 | id: cache-venv-and-cargo 57 | with: 58 | path: | 59 | .venv 60 | ~/.cargo/bin/ 61 | ~/.cargo/registry/index/ 62 | ~/.cargo/registry/cache/ 63 | ~/.cargo/git/db/ 64 | target/ 65 | key: ${{ hashFiles('**/dev-requirements.txt') }}-${{ steps.python-setup.outputs.python-version }}-${{ hashFiles('**/Cargo.lock') }}-0 66 | - run: | 67 | python -m venv .venv 68 | source .venv/bin/activate 69 | pip install -U pip setuptools wheel 70 | pip install -r dev-requirements.txt 71 | if: steps.cache-venv-and-cargo.outputs.cache-hit != 'true' 72 | - name: Run tests 73 | run: | 74 | source .venv/bin/activate 75 | source <(cargo llvm-cov show-env --export-prefix) 76 | export CARGO_TARGET_DIR=$CARGO_LLVM_COV_TARGET_DIR 77 | export CARGO_INCREMENTAL=1 78 | cargo llvm-cov clean --workspace 79 | cargo test 80 | maturin develop 81 | coverage run -m pytest tests 82 | coverage xml 83 | cargo llvm-cov report --lcov --output-path coverage.lcov --ignore-filename-regex "_errors|_tests|lib" 84 | - name: Run benchmarks 85 | run: | 86 | source .venv/bin/activate 87 | maturin build --release --locked --out ./ --strip --find-interpreter --no-default-features 88 | pip install ./self_limiters-* --force-reinstall 89 | python bench.py tb --count 30 --iterations 30 --target 0.25 90 | python bench.py s --count 30 --iterations 30 --target 1.5 91 | if: matrix.python-version == '3.11' 92 | - uses: codecov/codecov-action@v3 93 | with: 94 | files: coverage.lcov,coverage.xml 95 | if: matrix.python-version == '3.11' 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | .pytest_cache/ 6 | *.py[cod] 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | .venv/ 14 | env/ 15 | bin/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | include/ 26 | man/ 27 | venv/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | pip-selfcheck.json 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | 45 | # Translations 46 | *.mo 47 | 48 | # Mr Developer 49 | .mr.developer.cfg 50 | .project 51 | .pydevproject 52 | 53 | # Rope 54 | .ropeproject 55 | 56 | # Django stuff: 57 | *.log 58 | *.pot 59 | 60 | .DS_Store 61 | 62 | # Sphinx documentation 63 | docs/_build/ 64 | 65 | # PyCharm 66 | .idea/ 67 | 68 | # VSCode 69 | .vscode/ 70 | 71 | # Pyenv 72 | .python-version 73 | 74 | ARGO_LLVM_COV_TARGET_DIR 75 | 76 | .env 77 | 78 | default.profraw 79 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/doublify/pre-commit-rust 3 | rev: v1.0 4 | hooks: 5 | - id: fmt 6 | - id: cargo-check 7 | - id: clippy 8 | - repo: https://github.com/psf/black 9 | rev: 22.12.0 10 | hooks: 11 | - id: black 12 | 13 | - repo: https://github.com/pycqa/isort 14 | rev: 5.11.4 15 | hooks: 16 | - id: isort 17 | 18 | - repo: https://github.com/pre-commit/pre-commit-hooks 19 | rev: v4.4.0 20 | hooks: 21 | - id: check-ast 22 | - id: check-merge-conflict 23 | - id: check-case-conflict 24 | - id: check-docstring-first 25 | - id: check-json 26 | - id: check-yaml 27 | - id: end-of-file-fixer 28 | - id: trailing-whitespace 29 | - id: mixed-line-ending 30 | - id: trailing-whitespace 31 | - id: double-quote-string-fixer 32 | 33 | - repo: https://github.com/pycqa/flake8 34 | rev: 6.0.0 35 | hooks: 36 | - id: flake8 37 | additional_dependencies: [ 38 | 'flake8-bugbear', 39 | 'flake8-comprehensions', 40 | 'flake8-mutable', 41 | 'flake8-simplify', 42 | 'flake8-pytest-style', 43 | 'flake8-printf-formatting', 44 | 'flake8-type-checking', 45 | ] 46 | 47 | - repo: https://github.com/sirosen/check-jsonschema 48 | rev: 0.19.2 49 | hooks: 50 | - id: check-github-actions 51 | - id: check-github-workflows 52 | 53 | - repo: https://github.com/asottile/pyupgrade 54 | rev: v3.3.1 55 | hooks: 56 | - id: pyupgrade 57 | args: [ "--py36-plus", "--py37-plus", "--keep-runtime-typing" ] 58 | 59 | - repo: https://github.com/pre-commit/mirrors-mypy 60 | rev: v0.991 61 | hooks: 62 | - id: mypy 63 | additional_dependencies: 64 | - types-redis 65 | - pydantic 66 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are always welcome, and if you're unsure about something, please don't hesitate 4 | to open an issue. 5 | 6 | The rest of this file is dedicated document development and releases for things 7 | that I'll otherwise forget. 8 | 9 | ## Package version 10 | 11 | The package's version lives in `cargo.toml`, but is set 12 | in the release workflow based on the release tag. Release tags 13 | should therefore always conform to `v{0-9}.{0-9}.{0-9}` 14 | 15 | ## Debugging the Lua scripts 16 | 17 | Assuming you have a redis server running at `:6389` you can debug 18 | a lua script by calling `redis-cli -u redis://127.0.0.1:6389 --ldb --eval src/semaphore/rpushnx.lua x 1`. 19 | 20 | Just type `help` in the debugger for options. 21 | 22 | Another option is to run `MONITOR` in the redis-cli before running the relevant code, 23 | and checking the output of calls made. 24 | 25 | ## Setting up the environment 26 | 27 | 1. Create a venv 28 | 2. `pip install -r requirements-dev.txt` 29 | 2. `pre-commit install` 30 | 31 | And I think that's it! 32 | 33 | ## Running tests 34 | 35 | The tests rely on a Redis instance running on port 6389. 36 | Run `docker compose up -d` to start the dc redis. 37 | 38 | Rust tests are run with `cargo test`, while python tests can be run using `pytest .`. 39 | 40 | ## Coverage 41 | 42 | Since some of our tests are written in Rust, and some are written in Python, 43 | we've modelled our codecov setup after [this](https://github.com/cjermain/rust-python-coverage) 44 | project. The process consists of running both test suites with individual coverage tools, then 45 | patching the coverage data together via codecov. 46 | 47 | To run tests with coverage, you can use the `./coverage-report.sh` script. 48 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "ahash" 7 | version = "0.7.6" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" 10 | dependencies = [ 11 | "getrandom 0.2.8", 12 | "once_cell", 13 | "version_check", 14 | ] 15 | 16 | [[package]] 17 | name = "aho-corasick" 18 | version = "0.7.20" 19 | source = "registry+https://github.com/rust-lang/crates.io-index" 20 | checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" 21 | dependencies = [ 22 | "memchr", 23 | ] 24 | 25 | [[package]] 26 | name = "anyhow" 27 | version = "1.0.68" 28 | source = "registry+https://github.com/rust-lang/crates.io-index" 29 | checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61" 30 | 31 | [[package]] 32 | name = "arc-swap" 33 | version = "1.5.1" 34 | source = "registry+https://github.com/rust-lang/crates.io-index" 35 | checksum = "983cd8b9d4b02a6dc6ffa557262eb5858a27a0038ffffe21a0f133eaa819a164" 36 | 37 | [[package]] 38 | name = "arrayref" 39 | version = "0.3.6" 40 | source = "registry+https://github.com/rust-lang/crates.io-index" 41 | checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" 42 | 43 | [[package]] 44 | name = "arrayvec" 45 | version = "0.5.2" 46 | source = "registry+https://github.com/rust-lang/crates.io-index" 47 | checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" 48 | 49 | [[package]] 50 | name = "async-trait" 51 | version = "0.1.60" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | checksum = "677d1d8ab452a3936018a687b20e6f7cf5363d713b732b8884001317b0e48aa3" 54 | dependencies = [ 55 | "proc-macro2", 56 | "quote", 57 | "syn", 58 | ] 59 | 60 | [[package]] 61 | name = "autocfg" 62 | version = "1.1.0" 63 | source = "registry+https://github.com/rust-lang/crates.io-index" 64 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 65 | 66 | [[package]] 67 | name = "base64" 68 | version = "0.13.1" 69 | source = "registry+https://github.com/rust-lang/crates.io-index" 70 | checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" 71 | 72 | [[package]] 73 | name = "bb8" 74 | version = "0.8.0" 75 | source = "registry+https://github.com/rust-lang/crates.io-index" 76 | checksum = "1627eccf3aa91405435ba240be23513eeca466b5dc33866422672264de061582" 77 | dependencies = [ 78 | "async-trait", 79 | "futures-channel", 80 | "futures-util", 81 | "parking_lot", 82 | "tokio", 83 | ] 84 | 85 | [[package]] 86 | name = "bb8-redis" 87 | version = "0.12.0" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "8310da711a26fa0326202261abd73a414340f3ec957f63927a055511a01d7fb2" 90 | dependencies = [ 91 | "async-trait", 92 | "bb8", 93 | "redis", 94 | ] 95 | 96 | [[package]] 97 | name = "bitflags" 98 | version = "1.3.2" 99 | source = "registry+https://github.com/rust-lang/crates.io-index" 100 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 101 | 102 | [[package]] 103 | name = "blake2b_simd" 104 | version = "0.5.11" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587" 107 | dependencies = [ 108 | "arrayref", 109 | "arrayvec", 110 | "constant_time_eq", 111 | ] 112 | 113 | [[package]] 114 | name = "bstr" 115 | version = "0.2.17" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" 118 | dependencies = [ 119 | "lazy_static", 120 | "memchr", 121 | "regex-automata", 122 | ] 123 | 124 | [[package]] 125 | name = "byteorder" 126 | version = "1.4.3" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" 129 | 130 | [[package]] 131 | name = "bytes" 132 | version = "1.3.0" 133 | source = "registry+https://github.com/rust-lang/crates.io-index" 134 | checksum = "dfb24e866b15a1af2a1b663f10c6b6b8f397a84aadb828f12e5b289ec23a3a3c" 135 | 136 | [[package]] 137 | name = "camino" 138 | version = "1.1.1" 139 | source = "registry+https://github.com/rust-lang/crates.io-index" 140 | checksum = "88ad0e1e3e88dd237a156ab9f571021b8a158caa0ae44b1968a241efb5144c1e" 141 | dependencies = [ 142 | "serde", 143 | ] 144 | 145 | [[package]] 146 | name = "cargo-llvm-cov" 147 | version = "0.5.3" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "04d16ff8d380c58bb0affa6c4ef578473b95ea20a4248e5325a0be852622ea0d" 150 | dependencies = [ 151 | "anyhow", 152 | "camino", 153 | "cargo_metadata", 154 | "duct", 155 | "fs-err", 156 | "glob", 157 | "home", 158 | "is-terminal", 159 | "is_executable", 160 | "lcov2cobertura", 161 | "lexopt", 162 | "opener", 163 | "regex", 164 | "rustc-demangle", 165 | "serde", 166 | "serde_json", 167 | "shell-escape", 168 | "target-spec", 169 | "tempfile", 170 | "termcolor", 171 | "walkdir", 172 | ] 173 | 174 | [[package]] 175 | name = "cargo-platform" 176 | version = "0.1.2" 177 | source = "registry+https://github.com/rust-lang/crates.io-index" 178 | checksum = "cbdb825da8a5df079a43676dbe042702f1707b1109f713a01420fbb4cc71fa27" 179 | dependencies = [ 180 | "serde", 181 | ] 182 | 183 | [[package]] 184 | name = "cargo_metadata" 185 | version = "0.14.2" 186 | source = "registry+https://github.com/rust-lang/crates.io-index" 187 | checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" 188 | dependencies = [ 189 | "camino", 190 | "cargo-platform", 191 | "semver", 192 | "serde", 193 | "serde_json", 194 | ] 195 | 196 | [[package]] 197 | name = "cc" 198 | version = "1.0.78" 199 | source = "registry+https://github.com/rust-lang/crates.io-index" 200 | checksum = "a20104e2335ce8a659d6dd92a51a767a0c062599c73b343fd152cb401e828c3d" 201 | 202 | [[package]] 203 | name = "cfg-expr" 204 | version = "0.12.0" 205 | source = "registry+https://github.com/rust-lang/crates.io-index" 206 | checksum = "0bbc13bf6290a6b202cc3efb36f7ec2b739a80634215630c8053a313edf6abef" 207 | dependencies = [ 208 | "smallvec", 209 | "target-lexicon", 210 | ] 211 | 212 | [[package]] 213 | name = "cfg-if" 214 | version = "1.0.0" 215 | source = "registry+https://github.com/rust-lang/crates.io-index" 216 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 217 | 218 | [[package]] 219 | name = "clippy" 220 | version = "0.0.302" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "d911ee15579a3f50880d8c1d59ef6e79f9533127a3bd342462f5d584f5e8c294" 223 | dependencies = [ 224 | "term", 225 | ] 226 | 227 | [[package]] 228 | name = "combine" 229 | version = "4.6.6" 230 | source = "registry+https://github.com/rust-lang/crates.io-index" 231 | checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4" 232 | dependencies = [ 233 | "bytes", 234 | "futures-core", 235 | "memchr", 236 | "pin-project-lite", 237 | "tokio", 238 | "tokio-util", 239 | ] 240 | 241 | [[package]] 242 | name = "constant_time_eq" 243 | version = "0.1.5" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" 246 | 247 | [[package]] 248 | name = "crossbeam-utils" 249 | version = "0.8.14" 250 | source = "registry+https://github.com/rust-lang/crates.io-index" 251 | checksum = "4fb766fa798726286dbbb842f174001dab8abc7b627a1dd86e0b7222a95d929f" 252 | dependencies = [ 253 | "cfg-if", 254 | ] 255 | 256 | [[package]] 257 | name = "dirs" 258 | version = "1.0.5" 259 | source = "registry+https://github.com/rust-lang/crates.io-index" 260 | checksum = "3fd78930633bd1c6e35c4b42b1df7b0cbc6bc191146e512bb3bedf243fcc3901" 261 | dependencies = [ 262 | "libc", 263 | "redox_users", 264 | "winapi", 265 | ] 266 | 267 | [[package]] 268 | name = "duct" 269 | version = "0.13.6" 270 | source = "registry+https://github.com/rust-lang/crates.io-index" 271 | checksum = "37ae3fc31835f74c2a7ceda3aeede378b0ae2e74c8f1c36559fcc9ae2a4e7d3e" 272 | dependencies = [ 273 | "libc", 274 | "once_cell", 275 | "os_pipe", 276 | "shared_child", 277 | ] 278 | 279 | [[package]] 280 | name = "errno" 281 | version = "0.2.8" 282 | source = "registry+https://github.com/rust-lang/crates.io-index" 283 | checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" 284 | dependencies = [ 285 | "errno-dragonfly", 286 | "libc", 287 | "winapi", 288 | ] 289 | 290 | [[package]] 291 | name = "errno-dragonfly" 292 | version = "0.1.2" 293 | source = "registry+https://github.com/rust-lang/crates.io-index" 294 | checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" 295 | dependencies = [ 296 | "cc", 297 | "libc", 298 | ] 299 | 300 | [[package]] 301 | name = "fastrand" 302 | version = "1.8.0" 303 | source = "registry+https://github.com/rust-lang/crates.io-index" 304 | checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499" 305 | dependencies = [ 306 | "instant", 307 | ] 308 | 309 | [[package]] 310 | name = "form_urlencoded" 311 | version = "1.1.0" 312 | source = "registry+https://github.com/rust-lang/crates.io-index" 313 | checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" 314 | dependencies = [ 315 | "percent-encoding", 316 | ] 317 | 318 | [[package]] 319 | name = "fs-err" 320 | version = "2.9.0" 321 | source = "registry+https://github.com/rust-lang/crates.io-index" 322 | checksum = "0845fa252299212f0389d64ba26f34fa32cfe41588355f21ed507c59a0f64541" 323 | 324 | [[package]] 325 | name = "futures" 326 | version = "0.3.25" 327 | source = "registry+https://github.com/rust-lang/crates.io-index" 328 | checksum = "38390104763dc37a5145a53c29c63c1290b5d316d6086ec32c293f6736051bb0" 329 | dependencies = [ 330 | "futures-channel", 331 | "futures-core", 332 | "futures-executor", 333 | "futures-io", 334 | "futures-sink", 335 | "futures-task", 336 | "futures-util", 337 | ] 338 | 339 | [[package]] 340 | name = "futures-channel" 341 | version = "0.3.25" 342 | source = "registry+https://github.com/rust-lang/crates.io-index" 343 | checksum = "52ba265a92256105f45b719605a571ffe2d1f0fea3807304b522c1d778f79eed" 344 | dependencies = [ 345 | "futures-core", 346 | "futures-sink", 347 | ] 348 | 349 | [[package]] 350 | name = "futures-core" 351 | version = "0.3.25" 352 | source = "registry+https://github.com/rust-lang/crates.io-index" 353 | checksum = "04909a7a7e4633ae6c4a9ab280aeb86da1236243a77b694a49eacd659a4bd3ac" 354 | 355 | [[package]] 356 | name = "futures-executor" 357 | version = "0.3.25" 358 | source = "registry+https://github.com/rust-lang/crates.io-index" 359 | checksum = "7acc85df6714c176ab5edf386123fafe217be88c0840ec11f199441134a074e2" 360 | dependencies = [ 361 | "futures-core", 362 | "futures-task", 363 | "futures-util", 364 | ] 365 | 366 | [[package]] 367 | name = "futures-io" 368 | version = "0.3.25" 369 | source = "registry+https://github.com/rust-lang/crates.io-index" 370 | checksum = "00f5fb52a06bdcadeb54e8d3671f8888a39697dcb0b81b23b55174030427f4eb" 371 | 372 | [[package]] 373 | name = "futures-macro" 374 | version = "0.3.25" 375 | source = "registry+https://github.com/rust-lang/crates.io-index" 376 | checksum = "bdfb8ce053d86b91919aad980c220b1fb8401a9394410e1c289ed7e66b61835d" 377 | dependencies = [ 378 | "proc-macro2", 379 | "quote", 380 | "syn", 381 | ] 382 | 383 | [[package]] 384 | name = "futures-sink" 385 | version = "0.3.25" 386 | source = "registry+https://github.com/rust-lang/crates.io-index" 387 | checksum = "39c15cf1a4aa79df40f1bb462fb39676d0ad9e366c2a33b590d7c66f4f81fcf9" 388 | 389 | [[package]] 390 | name = "futures-task" 391 | version = "0.3.25" 392 | source = "registry+https://github.com/rust-lang/crates.io-index" 393 | checksum = "2ffb393ac5d9a6eaa9d3fdf37ae2776656b706e200c8e16b1bdb227f5198e6ea" 394 | 395 | [[package]] 396 | name = "futures-util" 397 | version = "0.3.25" 398 | source = "registry+https://github.com/rust-lang/crates.io-index" 399 | checksum = "197676987abd2f9cadff84926f410af1c183608d36641465df73ae8211dc65d6" 400 | dependencies = [ 401 | "futures-channel", 402 | "futures-core", 403 | "futures-io", 404 | "futures-macro", 405 | "futures-sink", 406 | "futures-task", 407 | "memchr", 408 | "pin-project-lite", 409 | "pin-utils", 410 | "slab", 411 | ] 412 | 413 | [[package]] 414 | name = "getrandom" 415 | version = "0.1.16" 416 | source = "registry+https://github.com/rust-lang/crates.io-index" 417 | checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" 418 | dependencies = [ 419 | "cfg-if", 420 | "libc", 421 | "wasi 0.9.0+wasi-snapshot-preview1", 422 | ] 423 | 424 | [[package]] 425 | name = "getrandom" 426 | version = "0.2.8" 427 | source = "registry+https://github.com/rust-lang/crates.io-index" 428 | checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" 429 | dependencies = [ 430 | "cfg-if", 431 | "libc", 432 | "wasi 0.11.0+wasi-snapshot-preview1", 433 | ] 434 | 435 | [[package]] 436 | name = "glob" 437 | version = "0.3.0" 438 | source = "registry+https://github.com/rust-lang/crates.io-index" 439 | checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" 440 | 441 | [[package]] 442 | name = "guppy-workspace-hack" 443 | version = "0.1.0" 444 | source = "registry+https://github.com/rust-lang/crates.io-index" 445 | checksum = "92620684d99f750bae383ecb3be3748142d6095760afd5cbcf2261e9a279d780" 446 | 447 | [[package]] 448 | name = "hermit-abi" 449 | version = "0.2.6" 450 | source = "registry+https://github.com/rust-lang/crates.io-index" 451 | checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" 452 | dependencies = [ 453 | "libc", 454 | ] 455 | 456 | [[package]] 457 | name = "home" 458 | version = "0.5.4" 459 | source = "registry+https://github.com/rust-lang/crates.io-index" 460 | checksum = "747309b4b440c06d57b0b25f2aee03ee9b5e5397d288c60e21fc709bb98a7408" 461 | dependencies = [ 462 | "winapi", 463 | ] 464 | 465 | [[package]] 466 | name = "idna" 467 | version = "0.3.0" 468 | source = "registry+https://github.com/rust-lang/crates.io-index" 469 | checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" 470 | dependencies = [ 471 | "unicode-bidi", 472 | "unicode-normalization", 473 | ] 474 | 475 | [[package]] 476 | name = "indoc" 477 | version = "1.0.8" 478 | source = "registry+https://github.com/rust-lang/crates.io-index" 479 | checksum = "da2d6f23ffea9d7e76c53eee25dfb67bcd8fde7f1198b0855350698c9f07c780" 480 | 481 | [[package]] 482 | name = "instant" 483 | version = "0.1.12" 484 | source = "registry+https://github.com/rust-lang/crates.io-index" 485 | checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" 486 | dependencies = [ 487 | "cfg-if", 488 | ] 489 | 490 | [[package]] 491 | name = "io-lifetimes" 492 | version = "1.0.3" 493 | source = "registry+https://github.com/rust-lang/crates.io-index" 494 | checksum = "46112a93252b123d31a119a8d1a1ac19deac4fac6e0e8b0df58f0d4e5870e63c" 495 | dependencies = [ 496 | "libc", 497 | "windows-sys", 498 | ] 499 | 500 | [[package]] 501 | name = "is-terminal" 502 | version = "0.4.2" 503 | source = "registry+https://github.com/rust-lang/crates.io-index" 504 | checksum = "28dfb6c8100ccc63462345b67d1bbc3679177c75ee4bf59bf29c8b1d110b8189" 505 | dependencies = [ 506 | "hermit-abi", 507 | "io-lifetimes", 508 | "rustix", 509 | "windows-sys", 510 | ] 511 | 512 | [[package]] 513 | name = "is_executable" 514 | version = "1.0.1" 515 | source = "registry+https://github.com/rust-lang/crates.io-index" 516 | checksum = "fa9acdc6d67b75e626ad644734e8bc6df893d9cd2a834129065d3dd6158ea9c8" 517 | dependencies = [ 518 | "winapi", 519 | ] 520 | 521 | [[package]] 522 | name = "itoa" 523 | version = "1.0.5" 524 | source = "registry+https://github.com/rust-lang/crates.io-index" 525 | checksum = "fad582f4b9e86b6caa621cabeb0963332d92eea04729ab12892c2533951e6440" 526 | 527 | [[package]] 528 | name = "lazy_static" 529 | version = "1.4.0" 530 | source = "registry+https://github.com/rust-lang/crates.io-index" 531 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 532 | 533 | [[package]] 534 | name = "lcov2cobertura" 535 | version = "1.0.1" 536 | source = "registry+https://github.com/rust-lang/crates.io-index" 537 | checksum = "cf88eb39b4cae8a9c79b5052613fa2489e7bd0fb2a4e46c9d6c908b4c2de162d" 538 | dependencies = [ 539 | "anyhow", 540 | "quick-xml", 541 | "regex", 542 | "rustc-demangle", 543 | ] 544 | 545 | [[package]] 546 | name = "lexopt" 547 | version = "0.2.1" 548 | source = "registry+https://github.com/rust-lang/crates.io-index" 549 | checksum = "478ee9e62aaeaf5b140bd4138753d1f109765488581444218d3ddda43234f3e8" 550 | 551 | [[package]] 552 | name = "libc" 553 | version = "0.2.139" 554 | source = "registry+https://github.com/rust-lang/crates.io-index" 555 | checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" 556 | 557 | [[package]] 558 | name = "linux-raw-sys" 559 | version = "0.1.4" 560 | source = "registry+https://github.com/rust-lang/crates.io-index" 561 | checksum = "f051f77a7c8e6957c0696eac88f26b0117e54f52d3fc682ab19397a8812846a4" 562 | 563 | [[package]] 564 | name = "lock_api" 565 | version = "0.4.9" 566 | source = "registry+https://github.com/rust-lang/crates.io-index" 567 | checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" 568 | dependencies = [ 569 | "autocfg", 570 | "scopeguard", 571 | ] 572 | 573 | [[package]] 574 | name = "log" 575 | version = "0.4.17" 576 | source = "registry+https://github.com/rust-lang/crates.io-index" 577 | checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" 578 | dependencies = [ 579 | "cfg-if", 580 | ] 581 | 582 | [[package]] 583 | name = "memchr" 584 | version = "2.5.0" 585 | source = "registry+https://github.com/rust-lang/crates.io-index" 586 | checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" 587 | 588 | [[package]] 589 | name = "memoffset" 590 | version = "0.6.5" 591 | source = "registry+https://github.com/rust-lang/crates.io-index" 592 | checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" 593 | dependencies = [ 594 | "autocfg", 595 | ] 596 | 597 | [[package]] 598 | name = "mio" 599 | version = "0.8.5" 600 | source = "registry+https://github.com/rust-lang/crates.io-index" 601 | checksum = "e5d732bc30207a6423068df043e3d02e0735b155ad7ce1a6f76fe2baa5b158de" 602 | dependencies = [ 603 | "libc", 604 | "log", 605 | "wasi 0.11.0+wasi-snapshot-preview1", 606 | "windows-sys", 607 | ] 608 | 609 | [[package]] 610 | name = "num_cpus" 611 | version = "1.15.0" 612 | source = "registry+https://github.com/rust-lang/crates.io-index" 613 | checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" 614 | dependencies = [ 615 | "hermit-abi", 616 | "libc", 617 | ] 618 | 619 | [[package]] 620 | name = "once_cell" 621 | version = "1.16.0" 622 | source = "registry+https://github.com/rust-lang/crates.io-index" 623 | checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860" 624 | 625 | [[package]] 626 | name = "opener" 627 | version = "0.5.0" 628 | source = "registry+https://github.com/rust-lang/crates.io-index" 629 | checksum = "4ea3ebcd72a54701f56345f16785a6d3ac2df7e986d273eb4395c0b01db17952" 630 | dependencies = [ 631 | "bstr", 632 | "winapi", 633 | ] 634 | 635 | [[package]] 636 | name = "os_pipe" 637 | version = "1.1.2" 638 | source = "registry+https://github.com/rust-lang/crates.io-index" 639 | checksum = "c6a252f1f8c11e84b3ab59d7a488e48e4478a93937e027076638c49536204639" 640 | dependencies = [ 641 | "libc", 642 | "windows-sys", 643 | ] 644 | 645 | [[package]] 646 | name = "parking_lot" 647 | version = "0.12.1" 648 | source = "registry+https://github.com/rust-lang/crates.io-index" 649 | checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" 650 | dependencies = [ 651 | "lock_api", 652 | "parking_lot_core", 653 | ] 654 | 655 | [[package]] 656 | name = "parking_lot_core" 657 | version = "0.9.5" 658 | source = "registry+https://github.com/rust-lang/crates.io-index" 659 | checksum = "7ff9f3fef3968a3ec5945535ed654cb38ff72d7495a25619e2247fb15a2ed9ba" 660 | dependencies = [ 661 | "cfg-if", 662 | "libc", 663 | "redox_syscall 0.2.16", 664 | "smallvec", 665 | "windows-sys", 666 | ] 667 | 668 | [[package]] 669 | name = "percent-encoding" 670 | version = "2.2.0" 671 | source = "registry+https://github.com/rust-lang/crates.io-index" 672 | checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" 673 | 674 | [[package]] 675 | name = "pin-project-lite" 676 | version = "0.2.9" 677 | source = "registry+https://github.com/rust-lang/crates.io-index" 678 | checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" 679 | 680 | [[package]] 681 | name = "pin-utils" 682 | version = "0.1.0" 683 | source = "registry+https://github.com/rust-lang/crates.io-index" 684 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 685 | 686 | [[package]] 687 | name = "proc-macro2" 688 | version = "1.0.49" 689 | source = "registry+https://github.com/rust-lang/crates.io-index" 690 | checksum = "57a8eca9f9c4ffde41714334dee777596264c7825420f521abc92b5b5deb63a5" 691 | dependencies = [ 692 | "unicode-ident", 693 | ] 694 | 695 | [[package]] 696 | name = "pyo3" 697 | version = "0.17.3" 698 | source = "registry+https://github.com/rust-lang/crates.io-index" 699 | checksum = "268be0c73583c183f2b14052337465768c07726936a260f480f0857cb95ba543" 700 | dependencies = [ 701 | "cfg-if", 702 | "indoc", 703 | "libc", 704 | "memoffset", 705 | "parking_lot", 706 | "pyo3-build-config", 707 | "pyo3-ffi", 708 | "pyo3-macros", 709 | "unindent", 710 | ] 711 | 712 | [[package]] 713 | name = "pyo3-asyncio" 714 | version = "0.17.0" 715 | source = "registry+https://github.com/rust-lang/crates.io-index" 716 | checksum = "1febe3946b26194628f00526929ee6f8559f9e807f811257e94d4c456103be0e" 717 | dependencies = [ 718 | "futures", 719 | "once_cell", 720 | "pin-project-lite", 721 | "pyo3", 722 | "tokio", 723 | ] 724 | 725 | [[package]] 726 | name = "pyo3-build-config" 727 | version = "0.17.3" 728 | source = "registry+https://github.com/rust-lang/crates.io-index" 729 | checksum = "28fcd1e73f06ec85bf3280c48c67e731d8290ad3d730f8be9dc07946923005c8" 730 | dependencies = [ 731 | "once_cell", 732 | "target-lexicon", 733 | ] 734 | 735 | [[package]] 736 | name = "pyo3-ffi" 737 | version = "0.17.3" 738 | source = "registry+https://github.com/rust-lang/crates.io-index" 739 | checksum = "0f6cb136e222e49115b3c51c32792886defbfb0adead26a688142b346a0b9ffc" 740 | dependencies = [ 741 | "libc", 742 | "pyo3-build-config", 743 | ] 744 | 745 | [[package]] 746 | name = "pyo3-log" 747 | version = "0.7.0" 748 | source = "registry+https://github.com/rust-lang/crates.io-index" 749 | checksum = "e5695ccff5060c13ca1751cf8c857a12da9b0bf0378cb071c5e0326f7c7e4c1b" 750 | dependencies = [ 751 | "arc-swap", 752 | "log", 753 | "pyo3", 754 | ] 755 | 756 | [[package]] 757 | name = "pyo3-macros" 758 | version = "0.17.3" 759 | source = "registry+https://github.com/rust-lang/crates.io-index" 760 | checksum = "94144a1266e236b1c932682136dc35a9dee8d3589728f68130c7c3861ef96b28" 761 | dependencies = [ 762 | "proc-macro2", 763 | "pyo3-macros-backend", 764 | "quote", 765 | "syn", 766 | ] 767 | 768 | [[package]] 769 | name = "pyo3-macros-backend" 770 | version = "0.17.3" 771 | source = "registry+https://github.com/rust-lang/crates.io-index" 772 | checksum = "c8df9be978a2d2f0cdebabb03206ed73b11314701a5bfe71b0d753b81997777f" 773 | dependencies = [ 774 | "proc-macro2", 775 | "quote", 776 | "syn", 777 | ] 778 | 779 | [[package]] 780 | name = "quick-xml" 781 | version = "0.26.0" 782 | source = "registry+https://github.com/rust-lang/crates.io-index" 783 | checksum = "7f50b1c63b38611e7d4d7f68b82d3ad0cc71a2ad2e7f61fc10f1328d917c93cd" 784 | dependencies = [ 785 | "memchr", 786 | ] 787 | 788 | [[package]] 789 | name = "quote" 790 | version = "1.0.23" 791 | source = "registry+https://github.com/rust-lang/crates.io-index" 792 | checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b" 793 | dependencies = [ 794 | "proc-macro2", 795 | ] 796 | 797 | [[package]] 798 | name = "redis" 799 | version = "0.22.1" 800 | source = "registry+https://github.com/rust-lang/crates.io-index" 801 | checksum = "513b3649f1a111c17954296e4a3b9eecb108b766c803e2b99f179ebe27005985" 802 | dependencies = [ 803 | "ahash", 804 | "async-trait", 805 | "bytes", 806 | "combine", 807 | "futures-util", 808 | "itoa", 809 | "percent-encoding", 810 | "pin-project-lite", 811 | "ryu", 812 | "sha1_smol", 813 | "tokio", 814 | "tokio-util", 815 | "url", 816 | ] 817 | 818 | [[package]] 819 | name = "redox_syscall" 820 | version = "0.1.57" 821 | source = "registry+https://github.com/rust-lang/crates.io-index" 822 | checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" 823 | 824 | [[package]] 825 | name = "redox_syscall" 826 | version = "0.2.16" 827 | source = "registry+https://github.com/rust-lang/crates.io-index" 828 | checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" 829 | dependencies = [ 830 | "bitflags", 831 | ] 832 | 833 | [[package]] 834 | name = "redox_users" 835 | version = "0.3.5" 836 | source = "registry+https://github.com/rust-lang/crates.io-index" 837 | checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d" 838 | dependencies = [ 839 | "getrandom 0.1.16", 840 | "redox_syscall 0.1.57", 841 | "rust-argon2", 842 | ] 843 | 844 | [[package]] 845 | name = "regex" 846 | version = "1.7.0" 847 | source = "registry+https://github.com/rust-lang/crates.io-index" 848 | checksum = "e076559ef8e241f2ae3479e36f97bd5741c0330689e217ad51ce2c76808b868a" 849 | dependencies = [ 850 | "aho-corasick", 851 | "memchr", 852 | "regex-syntax", 853 | ] 854 | 855 | [[package]] 856 | name = "regex-automata" 857 | version = "0.1.10" 858 | source = "registry+https://github.com/rust-lang/crates.io-index" 859 | checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" 860 | 861 | [[package]] 862 | name = "regex-syntax" 863 | version = "0.6.28" 864 | source = "registry+https://github.com/rust-lang/crates.io-index" 865 | checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" 866 | 867 | [[package]] 868 | name = "remove_dir_all" 869 | version = "0.5.3" 870 | source = "registry+https://github.com/rust-lang/crates.io-index" 871 | checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" 872 | dependencies = [ 873 | "winapi", 874 | ] 875 | 876 | [[package]] 877 | name = "rust-argon2" 878 | version = "0.8.3" 879 | source = "registry+https://github.com/rust-lang/crates.io-index" 880 | checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb" 881 | dependencies = [ 882 | "base64", 883 | "blake2b_simd", 884 | "constant_time_eq", 885 | "crossbeam-utils", 886 | ] 887 | 888 | [[package]] 889 | name = "rustc-demangle" 890 | version = "0.1.21" 891 | source = "registry+https://github.com/rust-lang/crates.io-index" 892 | checksum = "7ef03e0a2b150c7a90d01faf6254c9c48a41e95fb2a8c2ac1c6f0d2b9aefc342" 893 | 894 | [[package]] 895 | name = "rustix" 896 | version = "0.36.6" 897 | source = "registry+https://github.com/rust-lang/crates.io-index" 898 | checksum = "4feacf7db682c6c329c4ede12649cd36ecab0f3be5b7d74e6a20304725db4549" 899 | dependencies = [ 900 | "bitflags", 901 | "errno", 902 | "io-lifetimes", 903 | "libc", 904 | "linux-raw-sys", 905 | "windows-sys", 906 | ] 907 | 908 | [[package]] 909 | name = "ryu" 910 | version = "1.0.12" 911 | source = "registry+https://github.com/rust-lang/crates.io-index" 912 | checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde" 913 | 914 | [[package]] 915 | name = "same-file" 916 | version = "1.0.6" 917 | source = "registry+https://github.com/rust-lang/crates.io-index" 918 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 919 | dependencies = [ 920 | "winapi-util", 921 | ] 922 | 923 | [[package]] 924 | name = "scopeguard" 925 | version = "1.1.0" 926 | source = "registry+https://github.com/rust-lang/crates.io-index" 927 | checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" 928 | 929 | [[package]] 930 | name = "self-limiters" 931 | version = "0.0.0" 932 | dependencies = [ 933 | "bb8-redis", 934 | "cargo-llvm-cov", 935 | "clippy", 936 | "log", 937 | "pyo3", 938 | "pyo3-asyncio", 939 | "pyo3-log", 940 | "redis", 941 | "tokio", 942 | ] 943 | 944 | [[package]] 945 | name = "semver" 946 | version = "1.0.16" 947 | source = "registry+https://github.com/rust-lang/crates.io-index" 948 | checksum = "58bc9567378fc7690d6b2addae4e60ac2eeea07becb2c64b9f218b53865cba2a" 949 | dependencies = [ 950 | "serde", 951 | ] 952 | 953 | [[package]] 954 | name = "serde" 955 | version = "1.0.152" 956 | source = "registry+https://github.com/rust-lang/crates.io-index" 957 | checksum = "bb7d1f0d3021d347a83e556fc4683dea2ea09d87bccdf88ff5c12545d89d5efb" 958 | dependencies = [ 959 | "serde_derive", 960 | ] 961 | 962 | [[package]] 963 | name = "serde_derive" 964 | version = "1.0.152" 965 | source = "registry+https://github.com/rust-lang/crates.io-index" 966 | checksum = "af487d118eecd09402d70a5d72551860e788df87b464af30e5ea6a38c75c541e" 967 | dependencies = [ 968 | "proc-macro2", 969 | "quote", 970 | "syn", 971 | ] 972 | 973 | [[package]] 974 | name = "serde_json" 975 | version = "1.0.91" 976 | source = "registry+https://github.com/rust-lang/crates.io-index" 977 | checksum = "877c235533714907a8c2464236f5c4b2a17262ef1bd71f38f35ea592c8da6883" 978 | dependencies = [ 979 | "itoa", 980 | "ryu", 981 | "serde", 982 | ] 983 | 984 | [[package]] 985 | name = "sha1_smol" 986 | version = "1.0.0" 987 | source = "registry+https://github.com/rust-lang/crates.io-index" 988 | checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" 989 | 990 | [[package]] 991 | name = "shared_child" 992 | version = "1.0.0" 993 | source = "registry+https://github.com/rust-lang/crates.io-index" 994 | checksum = "b0d94659ad3c2137fef23ae75b03d5241d633f8acded53d672decfa0e6e0caef" 995 | dependencies = [ 996 | "libc", 997 | "winapi", 998 | ] 999 | 1000 | [[package]] 1001 | name = "shell-escape" 1002 | version = "0.1.5" 1003 | source = "registry+https://github.com/rust-lang/crates.io-index" 1004 | checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f" 1005 | 1006 | [[package]] 1007 | name = "signal-hook-registry" 1008 | version = "1.4.0" 1009 | source = "registry+https://github.com/rust-lang/crates.io-index" 1010 | checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" 1011 | dependencies = [ 1012 | "libc", 1013 | ] 1014 | 1015 | [[package]] 1016 | name = "slab" 1017 | version = "0.4.7" 1018 | source = "registry+https://github.com/rust-lang/crates.io-index" 1019 | checksum = "4614a76b2a8be0058caa9dbbaf66d988527d86d003c11a94fbd335d7661edcef" 1020 | dependencies = [ 1021 | "autocfg", 1022 | ] 1023 | 1024 | [[package]] 1025 | name = "smallvec" 1026 | version = "1.10.0" 1027 | source = "registry+https://github.com/rust-lang/crates.io-index" 1028 | checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" 1029 | 1030 | [[package]] 1031 | name = "socket2" 1032 | version = "0.4.7" 1033 | source = "registry+https://github.com/rust-lang/crates.io-index" 1034 | checksum = "02e2d2db9033d13a1567121ddd7a095ee144db4e1ca1b1bda3419bc0da294ebd" 1035 | dependencies = [ 1036 | "libc", 1037 | "winapi", 1038 | ] 1039 | 1040 | [[package]] 1041 | name = "syn" 1042 | version = "1.0.107" 1043 | source = "registry+https://github.com/rust-lang/crates.io-index" 1044 | checksum = "1f4064b5b16e03ae50984a5a8ed5d4f8803e6bc1fd170a3cda91a1be4b18e3f5" 1045 | dependencies = [ 1046 | "proc-macro2", 1047 | "quote", 1048 | "unicode-ident", 1049 | ] 1050 | 1051 | [[package]] 1052 | name = "target-lexicon" 1053 | version = "0.12.5" 1054 | source = "registry+https://github.com/rust-lang/crates.io-index" 1055 | checksum = "9410d0f6853b1d94f0e519fb95df60f29d2c1eff2d921ffdf01a4c8a3b54f12d" 1056 | 1057 | [[package]] 1058 | name = "target-spec" 1059 | version = "1.2.2" 1060 | source = "registry+https://github.com/rust-lang/crates.io-index" 1061 | checksum = "6a4b9859e2d5bf61d17ccdf2659396d69b207d956f2cb60e09df319394a8ccd4" 1062 | dependencies = [ 1063 | "cfg-expr", 1064 | "guppy-workspace-hack", 1065 | "target-lexicon", 1066 | ] 1067 | 1068 | [[package]] 1069 | name = "tempfile" 1070 | version = "3.3.0" 1071 | source = "registry+https://github.com/rust-lang/crates.io-index" 1072 | checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" 1073 | dependencies = [ 1074 | "cfg-if", 1075 | "fastrand", 1076 | "libc", 1077 | "redox_syscall 0.2.16", 1078 | "remove_dir_all", 1079 | "winapi", 1080 | ] 1081 | 1082 | [[package]] 1083 | name = "term" 1084 | version = "0.5.2" 1085 | source = "registry+https://github.com/rust-lang/crates.io-index" 1086 | checksum = "edd106a334b7657c10b7c540a0106114feadeb4dc314513e97df481d5d966f42" 1087 | dependencies = [ 1088 | "byteorder", 1089 | "dirs", 1090 | "winapi", 1091 | ] 1092 | 1093 | [[package]] 1094 | name = "termcolor" 1095 | version = "1.1.3" 1096 | source = "registry+https://github.com/rust-lang/crates.io-index" 1097 | checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" 1098 | dependencies = [ 1099 | "winapi-util", 1100 | ] 1101 | 1102 | [[package]] 1103 | name = "tinyvec" 1104 | version = "1.6.0" 1105 | source = "registry+https://github.com/rust-lang/crates.io-index" 1106 | checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" 1107 | dependencies = [ 1108 | "tinyvec_macros", 1109 | ] 1110 | 1111 | [[package]] 1112 | name = "tinyvec_macros" 1113 | version = "0.1.0" 1114 | source = "registry+https://github.com/rust-lang/crates.io-index" 1115 | checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" 1116 | 1117 | [[package]] 1118 | name = "tokio" 1119 | version = "1.23.0" 1120 | source = "registry+https://github.com/rust-lang/crates.io-index" 1121 | checksum = "eab6d665857cc6ca78d6e80303a02cea7a7851e85dfbd77cbdc09bd129f1ef46" 1122 | dependencies = [ 1123 | "autocfg", 1124 | "bytes", 1125 | "libc", 1126 | "memchr", 1127 | "mio", 1128 | "num_cpus", 1129 | "parking_lot", 1130 | "pin-project-lite", 1131 | "signal-hook-registry", 1132 | "socket2", 1133 | "tokio-macros", 1134 | "windows-sys", 1135 | ] 1136 | 1137 | [[package]] 1138 | name = "tokio-macros" 1139 | version = "1.8.2" 1140 | source = "registry+https://github.com/rust-lang/crates.io-index" 1141 | checksum = "d266c00fde287f55d3f1c3e96c500c362a2b8c695076ec180f27918820bc6df8" 1142 | dependencies = [ 1143 | "proc-macro2", 1144 | "quote", 1145 | "syn", 1146 | ] 1147 | 1148 | [[package]] 1149 | name = "tokio-util" 1150 | version = "0.7.4" 1151 | source = "registry+https://github.com/rust-lang/crates.io-index" 1152 | checksum = "0bb2e075f03b3d66d8d8785356224ba688d2906a371015e225beeb65ca92c740" 1153 | dependencies = [ 1154 | "bytes", 1155 | "futures-core", 1156 | "futures-sink", 1157 | "pin-project-lite", 1158 | "tokio", 1159 | "tracing", 1160 | ] 1161 | 1162 | [[package]] 1163 | name = "tracing" 1164 | version = "0.1.37" 1165 | source = "registry+https://github.com/rust-lang/crates.io-index" 1166 | checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" 1167 | dependencies = [ 1168 | "cfg-if", 1169 | "pin-project-lite", 1170 | "tracing-core", 1171 | ] 1172 | 1173 | [[package]] 1174 | name = "tracing-core" 1175 | version = "0.1.30" 1176 | source = "registry+https://github.com/rust-lang/crates.io-index" 1177 | checksum = "24eb03ba0eab1fd845050058ce5e616558e8f8d8fca633e6b163fe25c797213a" 1178 | dependencies = [ 1179 | "once_cell", 1180 | ] 1181 | 1182 | [[package]] 1183 | name = "unicode-bidi" 1184 | version = "0.3.8" 1185 | source = "registry+https://github.com/rust-lang/crates.io-index" 1186 | checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" 1187 | 1188 | [[package]] 1189 | name = "unicode-ident" 1190 | version = "1.0.6" 1191 | source = "registry+https://github.com/rust-lang/crates.io-index" 1192 | checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc" 1193 | 1194 | [[package]] 1195 | name = "unicode-normalization" 1196 | version = "0.1.22" 1197 | source = "registry+https://github.com/rust-lang/crates.io-index" 1198 | checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" 1199 | dependencies = [ 1200 | "tinyvec", 1201 | ] 1202 | 1203 | [[package]] 1204 | name = "unindent" 1205 | version = "0.1.11" 1206 | source = "registry+https://github.com/rust-lang/crates.io-index" 1207 | checksum = "e1766d682d402817b5ac4490b3c3002d91dfa0d22812f341609f97b08757359c" 1208 | 1209 | [[package]] 1210 | name = "url" 1211 | version = "2.3.1" 1212 | source = "registry+https://github.com/rust-lang/crates.io-index" 1213 | checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" 1214 | dependencies = [ 1215 | "form_urlencoded", 1216 | "idna", 1217 | "percent-encoding", 1218 | ] 1219 | 1220 | [[package]] 1221 | name = "version_check" 1222 | version = "0.9.4" 1223 | source = "registry+https://github.com/rust-lang/crates.io-index" 1224 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 1225 | 1226 | [[package]] 1227 | name = "walkdir" 1228 | version = "2.3.2" 1229 | source = "registry+https://github.com/rust-lang/crates.io-index" 1230 | checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" 1231 | dependencies = [ 1232 | "same-file", 1233 | "winapi", 1234 | "winapi-util", 1235 | ] 1236 | 1237 | [[package]] 1238 | name = "wasi" 1239 | version = "0.9.0+wasi-snapshot-preview1" 1240 | source = "registry+https://github.com/rust-lang/crates.io-index" 1241 | checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" 1242 | 1243 | [[package]] 1244 | name = "wasi" 1245 | version = "0.11.0+wasi-snapshot-preview1" 1246 | source = "registry+https://github.com/rust-lang/crates.io-index" 1247 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 1248 | 1249 | [[package]] 1250 | name = "winapi" 1251 | version = "0.3.9" 1252 | source = "registry+https://github.com/rust-lang/crates.io-index" 1253 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1254 | dependencies = [ 1255 | "winapi-i686-pc-windows-gnu", 1256 | "winapi-x86_64-pc-windows-gnu", 1257 | ] 1258 | 1259 | [[package]] 1260 | name = "winapi-i686-pc-windows-gnu" 1261 | version = "0.4.0" 1262 | source = "registry+https://github.com/rust-lang/crates.io-index" 1263 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1264 | 1265 | [[package]] 1266 | name = "winapi-util" 1267 | version = "0.1.5" 1268 | source = "registry+https://github.com/rust-lang/crates.io-index" 1269 | checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" 1270 | dependencies = [ 1271 | "winapi", 1272 | ] 1273 | 1274 | [[package]] 1275 | name = "winapi-x86_64-pc-windows-gnu" 1276 | version = "0.4.0" 1277 | source = "registry+https://github.com/rust-lang/crates.io-index" 1278 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1279 | 1280 | [[package]] 1281 | name = "windows-sys" 1282 | version = "0.42.0" 1283 | source = "registry+https://github.com/rust-lang/crates.io-index" 1284 | checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" 1285 | dependencies = [ 1286 | "windows_aarch64_gnullvm", 1287 | "windows_aarch64_msvc", 1288 | "windows_i686_gnu", 1289 | "windows_i686_msvc", 1290 | "windows_x86_64_gnu", 1291 | "windows_x86_64_gnullvm", 1292 | "windows_x86_64_msvc", 1293 | ] 1294 | 1295 | [[package]] 1296 | name = "windows_aarch64_gnullvm" 1297 | version = "0.42.0" 1298 | source = "registry+https://github.com/rust-lang/crates.io-index" 1299 | checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e" 1300 | 1301 | [[package]] 1302 | name = "windows_aarch64_msvc" 1303 | version = "0.42.0" 1304 | source = "registry+https://github.com/rust-lang/crates.io-index" 1305 | checksum = "dd0f252f5a35cac83d6311b2e795981f5ee6e67eb1f9a7f64eb4500fbc4dcdb4" 1306 | 1307 | [[package]] 1308 | name = "windows_i686_gnu" 1309 | version = "0.42.0" 1310 | source = "registry+https://github.com/rust-lang/crates.io-index" 1311 | checksum = "fbeae19f6716841636c28d695375df17562ca208b2b7d0dc47635a50ae6c5de7" 1312 | 1313 | [[package]] 1314 | name = "windows_i686_msvc" 1315 | version = "0.42.0" 1316 | source = "registry+https://github.com/rust-lang/crates.io-index" 1317 | checksum = "84c12f65daa39dd2babe6e442988fc329d6243fdce47d7d2d155b8d874862246" 1318 | 1319 | [[package]] 1320 | name = "windows_x86_64_gnu" 1321 | version = "0.42.0" 1322 | source = "registry+https://github.com/rust-lang/crates.io-index" 1323 | checksum = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed" 1324 | 1325 | [[package]] 1326 | name = "windows_x86_64_gnullvm" 1327 | version = "0.42.0" 1328 | source = "registry+https://github.com/rust-lang/crates.io-index" 1329 | checksum = "09d525d2ba30eeb3297665bd434a54297e4170c7f1a44cad4ef58095b4cd2028" 1330 | 1331 | [[package]] 1332 | name = "windows_x86_64_msvc" 1333 | version = "0.42.0" 1334 | source = "registry+https://github.com/rust-lang/crates.io-index" 1335 | checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" 1336 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "self-limiters" 3 | version = "0.0.0" # This is set on release in the release workflow 4 | edition = "2021" 5 | include = ["/src", "pyproject.toml"] 6 | 7 | [lib] 8 | name = "self_limiters" 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | pyo3 = { version = " >=0.17.2", features = ["extension-module", "abi3-py38"] } 13 | pyo3-log = ">=0.7.0" 14 | log = ">=0.4.17" 15 | pyo3-asyncio = { version = ">=0.17.0", features = ["tokio-runtime"] } 16 | tokio = {version=">=1.20.1", default-features=false} 17 | redis = { version=">=0.21.5", default-features=false, features = ["ahash", "script"] } 18 | bb8-redis = "0.12.0" 19 | 20 | [dev-dependencies] 21 | cargo-llvm-cov = { version = ">=0.4.1" } 22 | clippy = { version = ">=0.0.302" } 23 | 24 | [profile.release] 25 | opt-level = "s" 26 | overflow-checks = true 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 4-Clause License 2 | 3 | Copyright (c) 2022, Sondre Lillebø Gundersen 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. All advertising materials mentioning features or use of this software must 17 | display the following acknowledgement: 18 | This product includes software developed by [project]. 19 | 20 | 4. Neither the name of the copyright holder nor the names of its 21 | contributors may be used to endorse or promote products derived from 22 | this software without specific prior written permission. 23 | 24 | THIS SOFTWARE IS PROVIDED BY COPYRIGHT HOLDER "AS IS" AND ANY EXPRESS OR 25 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 26 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO 27 | EVENT SHALL COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 28 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 29 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 30 | OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 31 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 32 | OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 33 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PyPI 2 | test status 3 | coverage 4 | python version 5 | 6 | > Project no longer maintained 7 | > 8 | > This was a fun experiment, but since the library is i/o-bound, the long-term maintenance burden of a Rust Python plugin did not seem worth it. Instead I decided to re-implement this in Python. 9 | > 10 | > See https://github.com/otovo/redis-rate-limiters for the python version :) 11 | 12 | 13 | # Self limiters 14 | 15 | A library for regulating traffic with respect to **concurrency** or **time**. 16 | 17 | It implements a [semaphore](https://en.wikipedia.org/wiki/Semaphore_(programming)) to be used when you need to 18 | limit the number of *concurrent* requests to an API (or other resources). For example if you can at most 19 | send 5 requests at the same time. 20 | 21 | It also implements the [token bucket algorithm](https://en.wikipedia.org/wiki/Token_bucket) which can be used 22 | to limit the number of requests made in a given time interval. For example if you're restricted to 23 | sending, at most, 10 requests per second. 24 | 25 | Both limiters are async, FIFO, and distributed using Redis. This means the limiters can be 26 | used across several distributed processes. If that isn't your use-case, you should probably 27 | look for another library which does this in memory. 28 | 29 | The package was written with rate-limiting in mind, but the semaphore and token bucket 30 | implementations can be used for anything. 31 | 32 | # Installation 33 | 34 | ``` 35 | pip install self-limiters 36 | ``` 37 | 38 | # Usage 39 | 40 | Both implementations are written as async context managers. 41 | 42 | ### Semaphore 43 | 44 | The `Semaphore` can be used like this: 45 | 46 | ```python 47 | from self_limiters import Semaphore 48 | 49 | 50 | # 5 requests at the time 51 | async with Semaphore(name="", capacity=5, max_sleep=60, redis_url=""): 52 | client.get(...) 53 | ``` 54 | 55 | The `name` given is what determines whether your processes will use the same limiter or not. 56 | 57 | The semaphore implementation is largely a wrapper around the [`blpop`](https://redis.io/commands/blpop/) 58 | redis command. We use it to wait for the semaphore to be freed up, in a non-blocking way. 59 | 60 | If you specify a non-zero `max_sleep`, a `MaxSleepExceededError` will be raised if `blpop` waits for longer than that specified value. 61 | 62 | ### Token bucket 63 | 64 | The `TokenBucket` context manager is used the same way, like this: 65 | 66 | ```python 67 | from self_limiters import TokenBucket 68 | 69 | 70 | # 1 requests per minute 71 | async with TokenBucket( 72 | name="", 73 | capacity=1, 74 | refill_amount=1, 75 | refill_frequency=60, 76 | max_sleep=600, 77 | redis_url="" 78 | ): 79 | client.get(...) 80 | ``` 81 | 82 | The limiter first estimates when there will be capacity in the bucket - i.e., when it's this instances turn to go, 83 | then async sleeps until then. 84 | 85 | If `max_sleep` is set and the estimated sleep time exceeds this, a `MaxSleepExceededError` 86 | is raised immediately. 87 | 88 | ### As a decorator 89 | 90 | The package doesn't ship any decorators, but if you would 91 | like to limit the rate at which a whole function is run, 92 | you can create your own, like this: 93 | 94 | ```python 95 | from self_limiters import Semaphore 96 | 97 | 98 | # Define a decorator function 99 | def limit(name, capacity): 100 | def middle(f): 101 | async def inner(*args, **kwargs): 102 | async with Semaphore( 103 | name=name, 104 | capacity=capacity, 105 | redis_url="redis://127.0.0.1:6389" 106 | ): 107 | return await f(*args, **kwargs) 108 | return inner 109 | return middle 110 | 111 | 112 | # Then pass the relevant limiter arguments like this 113 | @limit(name="foo", capacity=5) 114 | def fetch_foo(id: UUID) -> Foo: 115 | ``` 116 | 117 | # Implementation and general flow 118 | 119 | The library is written in Rust (for fun) and more importantly, relies on 120 | [Lua](http://www.lua.org/about.html) scripts and 121 | [pipelining](https://docs.rs/redis/0.22.0/redis/struct.Pipeline.html) to 122 | improve the performance of each implementation. 123 | 124 | Redis lets users upload and execute Lua scripts on the server directly, meaning we can write 125 | e.g., the entire token bucket logic in Lua. Using Lua scripts presents a couple of nice benefits: 126 | 127 | - Since they are executed on the redis instance, we can make 1 request to redis 128 | where we would otherwise have to make 3 or 4. The time saved by reducing 129 | the number of requests can be significant. 130 | 131 | - Redis is single-threaded and guarantees atomic execution of scripts, meaning 132 | we don't have to worry about data races. As a consequence, our implementations 133 | become FIFO out of the box. Without atomic execution we'd need distributed locks to prevent race conditions, which would 134 | be very expensive. 135 | 136 | So Lua scripts help make our implementation faster and simpler. 137 | 138 | This is the rough flow of execution, for each implementation: 139 | 140 | ### The semaphore implementation 141 | 142 | 1. Run a [lua script](https://github.com/snok/self-limiters/blob/main/scripts/semaphore.lua) 143 | to create a list data structure in redis, as the foundation of the semaphore. 144 | 145 | This script is idempotent, and skipped if it has already been created. 146 | 147 | 2. Run [`BLPOP`](https://redis.io/commands/blpop/) to non-blockingly wait until the semaphore has capacity, 148 | and pop from the list when it does. 149 | 150 | 3. Then run a [pipelined command](https://github.com/snok/self-limiters/blob/main/src/semaphore.rs#L78:L83) 151 | to release the semaphore by adding back the capacity borrowed. 152 | 153 | So in total we make 3 calls to redis, which are all non-blocking. 154 | 155 | ### The token bucket implementation 156 | 157 | The token bucket implementation is even simpler. The steps are: 158 | 159 | 1. Run a [lua script](https://github.com/snok/self-limiters/blob/main/scripts/token_bucket.lua) 160 | to estimate and return a wake-up time. 161 | 162 | 2. Sleep until the given timestamp. 163 | 164 | We make one call, then sleep. Both are non-blocking. 165 | 166 | --------- 167 | 168 | So in summary, almost all of the time is spent waiting for async i/o, meaning the limiters' impact on an 169 | application event-loop should be close to completely negligible. 170 | 171 | ## Benchmarks 172 | 173 | We run benchmarks in CI with Github actions. For a normal `ubuntu-latest` runner, we see runtimes for both limiters: 174 | 175 | When creating 100 instances of each implementation and calling them at the same time, the average runtimes are: 176 | 177 | - Semaphore implementation: ~0.6ms per instance 178 | - Token bucket implementation: ~0.03ms per instance 179 | 180 | Take a look at the [benchmarking script](https://github.com/snok/self-limiters/blob/main/src/bench.py) if you want 181 | to run your own tests! 182 | 183 | # Implementation reference 184 | 185 | ## The semaphore implementation 186 | 187 | The semaphore implementation is useful when you need to limit a process 188 | to `n` concurrent actions. For example if you have several web servers, and 189 | you're interacting with an API that will only tolerate a certain amount of 190 | concurrent requests before locking you out. 191 | 192 | The flow can be broken down as follows: 193 | 194 | 195 | 196 | The initial [lua script](https://github.com/snok/self-limiters/blob/main/scripts/semaphore.lua) 197 | first checks if the redis list we will build the semaphore on exists or not. 198 | It does this by calling [`SETNX`](https://redis.io/commands/setnx/) on the key of the queue plus a postfix 199 | (if the `name` specified in the class instantiation is "my-queue", then the queue name will be 200 | `__self-limiters:my-queue` and setnx will be called for `__self-limiters:my-queue-exists`). If the returned 201 | value is 1 it means the queue we will use for our semaphore does not exist yet and needs to be created. 202 | 203 | It might strike you as weird to maintain a separate value, just to indicate whether a list exists, 204 | when we could just check the list itself. It would be nice if we could use 205 | [`EXISTS`](https://redis.io/commands/exists/) on the list directly, but unfortunately a list is considered 206 | not to exist when all elements are popped (i.e., when a semaphore is fully acquired), so I don't see 207 | another way of doing this. Contributions are very welcome if you do! 208 |

209 | Then if the queue needs to be created we call [`RPUSH`](https://redis.io/commands/rpush/) with the number of arguments 210 | equal to the `capacity` value used when initializing the semaphore instance. For a semaphore with 211 | a capacity of 5, we call `RPUSH 1 1 1 1 1`, where the values are completely arbitrary. 212 | 213 | Once the list/queue has been created, we [`BLPOP`](https://redis.io/commands/blpop/) to block until it's 214 | our turn. `BLPOP` is FIFO by default. We also make sure to specify the `max_sleep` based on the initialized 215 | semaphore instance setting. If nothing was passed we allow sleeping forever. 216 | 217 | On `__aexit__` we run three commands in a pipelined query. We [`RPUSH`](https://redis.io/commands/rpush/) a `1` 218 | back into the queue to "release" the semaphore, and set an expiry on the queue and the string value we called 219 | `SETNX` on. 220 |

221 | The expires are a half measure for dealing with dropped capacity. If a node holding the semaphore dies, 222 | the capacity might never be returned. If, however, there is no one using the semaphore for the duration of the 223 | expiry value, all values will be cleared, and the semaphore will be recreated at full capacity next time it's used. 224 | The expiry is 30 seconds at the time of writing, but could be made configurable. 225 | 226 | ### The token bucket implementation 227 | 228 | The token bucket implementation is useful when you need to limit a process by 229 | a time interval. For example, to 1 request per minute, or 50 requests every 10 seconds. 230 | 231 | The implementation is forward-looking. It works out the time there *would have been* 232 | capacity in the bucket for a given client and returns that time. From there we can 233 | asynchronously sleep until it's time to perform our rate limited action. 234 | 235 | The flow can be broken down as follows: 236 | 237 | 238 | 239 | Call the [schedule Lua script](https://github.com/snok/self-limiters/blob/main/scripts/token_bucket.lua) 240 | which first [`GET`](https://redis.io/commands/get/)s the *state* of the bucket. 241 | 242 | The bucket state contains the last time slot scheduled and the number of tokens left for that time slot. 243 | With a capacity of 1, having a `tokens_left_for_slot` variable makes no sense, but if there's 244 | capacity of 2 or more, it is possible that we will need to schedule multiple clients to the 245 | same time slot. 246 | 247 | The script then works out whether to decrement the `tokens_left_for_slot` value, or to 248 | increment the time slot value wrt. the frequency variable. 249 | 250 | Finally, we store the bucket state again using [`SETEX`](https://redis.io/commands/setex/). 251 | This allows us to store the state and set expiry at the same time. The default expiry 252 | is 30 at the time of writing, but could be made configurable. 253 | 254 | One thing to note, is that this would not work if it wasn't for the fact that redis is single threaded, 255 | so Lua scripts on Redis are FIFO. Without this we would need locks and a lot more logic. 256 | 257 | Then we just sleep! 258 | 259 | # Contributing 260 | 261 | Please do! Feedback on the implementation, issues, and PRs are all welcome. See [`CONTRIBUTING.md`](https://github.com/snok/self-limiters/blob/main/CONTRIBUTING.md) for more details. 262 | 263 | Please also consider starring the repo to raise visibility. 264 | -------------------------------------------------------------------------------- /bench.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | from datetime import datetime, timedelta 6 | from functools import partial 7 | from statistics import median 8 | from typing import Optional, Union 9 | from uuid import uuid4 10 | 11 | import typer 12 | from self_limiters import Semaphore, TokenBucket 13 | 14 | FORMAT = '[%(asctime)s] %(message)s' 15 | logging.basicConfig(format=FORMAT, level=logging.DEBUG) 16 | 17 | 18 | async def run(t: Union[Semaphore, TokenBucket], sleep: float): 19 | """Run context manager.""" 20 | async with t: 21 | await asyncio.sleep(sleep) 22 | 23 | 24 | async def run_n(*, n: int, t: Union[Semaphore, TokenBucket], sleep: float): 25 | """Call run `n` times for the same limiter.""" 26 | tasks = [asyncio.create_task(run(t, sleep)) for _ in range(n)] 27 | start = datetime.now() 28 | await asyncio.gather(*tasks) 29 | return datetime.now() - start 30 | 31 | 32 | async def run_iterations(t: partial, sleep: float, count: int, iterations: int): 33 | """Call run_n for `iterations` permutations of a limiter.""" 34 | return [await run_n(n=count, t=t(name=uuid4().hex[:6]), sleep=sleep) for _ in range(iterations)] 35 | 36 | 37 | def main( 38 | type: str, 39 | count: int = 10, 40 | iterations: int = 10, 41 | target: Optional[float] = None, 42 | capacity: int = 1, 43 | max_sleep: float = 0.0, 44 | redis_url: str = 'redis://127.0.0.1:6389', 45 | sleep: float = 0.0, 46 | ): 47 | """ 48 | Runs a simple benchmark using the library limiters. 49 | 50 | Can be run like: 51 | 52 | python bench.py s \ 53 | --count 100 \ 54 | --iterations 12 \ 55 | --target 4 56 | 57 | :param type: Which of the limiters to use. Semaphore or TokenBucket. 58 | :param count: How many context managers to run for a single limiter. 59 | :param iterations: How many limiters to run. 60 | :param target: What maximum time to allow, in ms. 61 | :param capacity: The limiter capacity. 62 | :param max_sleep: The limiter max sleep. 63 | :param redis_url: Redis connection string. 64 | :param sleep: How long to sleep before exiting context manager closure. 65 | :return: Nothing. 66 | """ 67 | t: partial 68 | if type.startswith('s'): 69 | typer.echo('Testing semaphore...') 70 | t = partial(Semaphore, capacity=capacity, max_sleep=max_sleep, redis_url=redis_url, connection_pool_size=30) 71 | offset = 0.0 72 | elif type.startswith('t'): 73 | typer.echo('Testing token bucket...') 74 | t = partial( 75 | TokenBucket, 76 | refill_frequency=0.01, 77 | refill_amount=1, 78 | capacity=capacity, 79 | max_sleep=max_sleep, 80 | redis_url=redis_url, 81 | connection_pool_size=30, 82 | ) 83 | offset = 0.01 * count 84 | else: 85 | typer.echo(f'type must be \'semaphore\' or \'token_bucket\', not {type}', color=True, err=True) 86 | return exit(1) 87 | 88 | times: list[timedelta] = asyncio.run(run_iterations(t, sleep, count, iterations)) 89 | 90 | seconds = [t.seconds + t.microseconds / 1_000_000 - offset for t in times] 91 | 92 | # discard first iterations 93 | n = len(seconds) // 10 94 | print(f'Discarding {n} first samples') 95 | seconds = seconds[n:] 96 | 97 | avg = sum(seconds) / iterations / count * 1000 98 | med = median(seconds) / count * 1000 99 | print(f'Average was {avg :.2f}ms per run') 100 | print(f'Median was {med:.2f}ms per run') 101 | 102 | if target: 103 | assert med <= target, f'Median time of {med}ms was not above target of {target}ms' 104 | print(f'Median time was below target of {target}ms') 105 | 106 | 107 | if __name__ == '__main__': 108 | typer.run(main) 109 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | 3 | fn read_script(filename: &str) -> String { 4 | fs::read_to_string(format!("./scripts/{}.lua", filename)).ok().unwrap() 5 | } 6 | 7 | fn main() -> Result<(), Box> { 8 | let semaphore_script_contents = read_script("semaphore"); 9 | let token_bucket_script_contents = read_script("token_bucket"); 10 | 11 | let mut file_content = "\ 12 | /// This file is generated with a build script. 13 | /// 14 | /// Do not make changes to this file. Instead edit the Lua scripts directly. 15 | 16 | " 17 | .to_string(); 18 | file_content += &format!( 19 | "pub const SEMAPHORE_SCRIPT: &str = \"\\\n{}\";\n", 20 | semaphore_script_contents 21 | ); 22 | file_content += &format!( 23 | "pub const TOKEN_BUCKET_SCRIPT: &str = \"\\\n{}\";\n", 24 | token_bucket_script_contents 25 | ); 26 | 27 | fs::write("src/generated.rs", file_content).unwrap(); 28 | Ok(()) 29 | } 30 | -------------------------------------------------------------------------------- /coverage-report.sh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | # Before running this, you'll need to run `rustup component add llvm-tools-preview` 4 | 5 | set -euo pipefail 6 | 7 | cargo llvm-cov show-env --export-prefix 8 | export RUSTFLAGS=" -C instrument-coverage --cfg coverage --cfg trybuild_no_target" 9 | export LLVM_PROFILE_FILE="$PWD/target/self-limiters-%m.profraw" 10 | export CARGO_INCREMENTAL="0" 11 | export CARGO_LLVM_COV_TARGET_DIR="$PWD/target" 12 | 13 | source <(cargo llvm-cov show-env --export-prefix) 14 | 15 | export CARGO_TARGET_DIR=$CARGO_LLVM_COV_TARGET_DIR 16 | export CARGO_INCREMENTAL=1 17 | cargo llvm-cov clean --workspace 18 | cargo test 19 | source "$VIRTUAL_ENV"/bin/activate 20 | maturin develop 21 | coverage run -m pytest tests 22 | 23 | cargo llvm-cov report --ignore-filename-regex "errors|_tests|lib" 24 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-randomly 3 | pytest-asyncio 4 | pre-commit 5 | coverage 6 | maturin[zig] 7 | uvloop 8 | coverage 9 | redis 10 | types-redis 11 | typer 12 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | redis: 5 | container_name: 'sl-redis' 6 | image: redis:alpine 7 | command: redis-server --appendonly yes 8 | ports: 9 | - '127.0.0.1:6389:6379' 10 | -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | self-limiters 3 | 4 | 7 | 8 | -------------------------------------------------------------------------------- /docs/semaphore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/self-limiters/28092ab71d71c1686826c02c14b464b2fcee367e/docs/semaphore.png -------------------------------------------------------------------------------- /docs/semaphore.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15 | 16 | Call scriptreturnaenteraexitWait for semaphoreCreate queue if not existsAdd initial capacityalready existedreturnwas setreturnLua scriptCall scriptreturnAdd capacity backto semaphoreSet expiryLua scriptreturn 17 | -------------------------------------------------------------------------------- /docs/token_bucket.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/self-limiters/28092ab71d71c1686826c02c14b464b2fcee367e/docs/token_bucket.png -------------------------------------------------------------------------------- /docs/token_bucket.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15 | 16 | aenterLua scriptFetch slotreturnSleep until slotRetrieve bucket stateFind next slotSave bucket statereturn 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "self-limiters" 3 | description = "Distributed async rate limiters, using Redis" 4 | authors = [{ name = "Sondre Lillebø Gundersen", email = "sondrelg@live.no" }] 5 | readme = "README.md" 6 | requires-python = ">=3.9" 7 | license = { file = "LICENSE", type = 'BSD-3' } 8 | homepage = "https://github.com/sondrelg/self-limiters" 9 | repository = "https://github.com/sondrelg/self-limiters" 10 | keywords = [ 11 | "distributed", 12 | "async", 13 | "rate-limit", 14 | "rate", 15 | "limit", 16 | "limiting", 17 | "redis", 18 | "rust", 19 | "semaphore", 20 | "token", 21 | "leaky", 22 | "bucket", 23 | "tokenbucket", 24 | ] 25 | dependencies = [] 26 | classifiers = [ 27 | "Development Status :: 4 - Beta", 28 | "Programming Language :: Rust", 29 | 'Programming Language :: Python', 30 | 'Programming Language :: Python :: 3.9', 31 | 'Programming Language :: Python :: 3.10', 32 | 'Programming Language :: Python :: 3.11', 33 | "Programming Language :: Python :: Implementation :: CPython", 34 | "Programming Language :: Python :: Implementation :: PyPy", 35 | 'License :: OSI Approved :: BSD License', 36 | 'Intended Audience :: Developers', 37 | ] 38 | 39 | [tool.maturin] 40 | bindings = "pyo3" 41 | strip = true 42 | 43 | [project.urls] 44 | releases = "https://github.com/sondrelg/self-limiters/releases" 45 | 46 | [build-system] 47 | requires = ["maturin>=0.13,<0.14"] 48 | build-backend = "maturin" 49 | 50 | [tool.black] 51 | line-length = 120 52 | skip-string-normalization = true 53 | quiet = true 54 | preview = true 55 | target-version = ["py38"] 56 | 57 | [tool.isort] 58 | profile = "black" 59 | line_length = 120 60 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 120 2 | -------------------------------------------------------------------------------- /scripts/semaphore.lua: -------------------------------------------------------------------------------- 1 | --- Script called from the Semaphore implementation. 2 | --- 3 | --- Lua scripts are run atomically by default, and since redis 4 | --- is single threaded, there are no race conditions to worry about. 5 | --- 6 | --- The script checks if a list exists for the Semaphore, and 7 | --- creates one of length `capacity` if it doesn't. 8 | --- 9 | --- keys: 10 | --- * key: The key to use for the list 11 | --- * existskey: The key to use for the string we use to check if the lists exists 12 | --- 13 | --- args: 14 | --- * capacity: The capacity of the semaphore (i.e., the length of the list) 15 | --- 16 | --- returns: 17 | --- * 1 if created, else 0 (but the return value isn't used; only useful for debugging) 18 | 19 | redis.replicate_commands() 20 | 21 | -- Init config variables 22 | local key = tostring(KEYS[1]) 23 | local existskey = tostring(KEYS[2]) 24 | local capacity = tonumber(ARGV[1]) 25 | 26 | -- Check if list exists 27 | -- Note, we cannot use EXISTS or LLEN below, as we need 28 | -- to know if a list exists, but has capacity zero. 29 | local does_not_exist = redis.call('SETNX', string.format(existskey, key), 1) 30 | 31 | -- Create the list if none exists 32 | if does_not_exist == 1 then 33 | -- Add '1' as an argument equal to the capacity of the semaphore 34 | -- If capacity is 5 here, we generate `{RPUSH, 1, 1, 1, 1, 1}`. 35 | local args = { 'RPUSH', key } 36 | for _ = 1, capacity do 37 | table.insert(args, 1) 38 | end 39 | redis.call(unpack(args)) 40 | return true 41 | end 42 | return false 43 | -------------------------------------------------------------------------------- /scripts/token_bucket.lua: -------------------------------------------------------------------------------- 1 | --- Script called from the Semaphore implementation. 2 | --- partially modelled after https://github.com/Tinche/aiosteady 3 | --- 4 | --- Lua scripts are run atomically by default, and since redis 5 | --- is single threaded, there are no race conditions to worry about. 6 | --- 7 | --- This script does three things, in order: 8 | --- 1. Retrieves token bucket state, which means the last slot assigned, 9 | --- and how many tokens are left to be assigned for that slot 10 | --- 2. Works out whether we need to move to the next slot, or consume another 11 | --- token from the current one. 12 | --- 3. Saves the token bucket state and returns the slot. 13 | --- 14 | --- The token bucket implementation is forward looking, so we're really just handing 15 | --- out the next time there would be tokens in the bucket, and letting the client 16 | --- sleep until then. This would be terrible in a sync application, but for an async 17 | --- python task runner or web-server, it's actually very handy. There is the issue 18 | --- of processes sleeping for an unreasonably long time, but there is a max-sleep 19 | --- setting in both implementations to offset this. 20 | --- 21 | --- keys: 22 | --- * key: The key name to use for the semaphore 23 | --- 24 | --- args: 25 | --- * capacity: The max capacity of the bucket 26 | --- * refill_rate: How often tokens are added to the bucket, (NOTE) in *milliseconds* 27 | --- The rate is in milliseconds since we cannot use floats for the `now` variable. 28 | --- This deviates from the rest of the package code, where the rate is specified in seconds. 29 | --- * refill_amount: How many tokens are added at each interval 30 | --- 31 | --- returns: 32 | --- * The assigned slot, as a millisecond timestamp 33 | 34 | redis.replicate_commands() 35 | 36 | -- Init config variables 37 | local data_key = KEYS[1] 38 | local capacity = tonumber(ARGV[1]) 39 | local refill_rate = tonumber(ARGV[2]) 40 | local refill_amount = tonumber(ARGV[3]) 41 | 42 | -- Get current time (ms timestamp) 43 | local redis_time = redis.call('TIME') -- Array of [seconds, microseconds] 44 | local now = tonumber(redis_time[1]) * 1000 + (tonumber(redis_time[2]) / 1000) 45 | 46 | -- Instantiate default bucket values 47 | -- These are used if no state is retrieved below; i.e., they 48 | -- are the values we use for creating a new bucket. 49 | local tokens = refill_amount 50 | local slot = now + refill_rate 51 | 52 | -- Retrieve (possibly) stored state 53 | local data = redis.call('GET', data_key) 54 | 55 | if data ~= false then 56 | for a, b in string.gmatch(data, '(%S+) (%S+)') do 57 | slot = tonumber(a) 58 | tokens = tonumber(b) 59 | end 60 | 61 | -- Quickly validate our state 62 | 63 | -- If the slot is in the past, we need to increment the slot 64 | -- value, and add tokens to the bucket equal to the slots skipped 65 | if slot < now + 20 then 66 | tokens = tokens + (slot - now) / refill_rate 67 | slot = slot + refill_rate 68 | 69 | -- If we skipped 3 slots, but the capacity is 1, 70 | -- trim the tokens left. 71 | if tokens > capacity then 72 | tokens = capacity 73 | end 74 | end 75 | 76 | -- If the current slot has no more tokens to assign, 77 | -- move to the next slot. 78 | if tokens <= 0 then 79 | slot = slot + refill_rate 80 | tokens = refill_amount 81 | end 82 | end 83 | 84 | -- Consume a token 85 | tokens = tokens - 1 86 | 87 | -- Save state and set expiry 88 | redis.call('SETEX', data_key, 30, string.format('%d %d', slot, tokens)) 89 | 90 | return slot 91 | -------------------------------------------------------------------------------- /self_limiters.pyi: -------------------------------------------------------------------------------- 1 | from types import TracebackType 2 | from typing import Optional 3 | 4 | class TokenBucket: 5 | def __init__( 6 | self, 7 | name: str, 8 | capacity: int, 9 | refill_frequency: float, 10 | refill_amount: int, 11 | redis_url: Optional[str] = None, # will be set as "redis://127.0.0.1:6379" if None 12 | max_sleep: Optional[float] = None, # will be set to 0.0 if None. In seconds. 13 | connection_pool_size: Optional[int] = None, # Will be set to 15 if None 14 | ) -> None: ... 15 | 16 | capacity: int 17 | name: str 18 | refill_frequency: float 19 | refill_amount: int 20 | 21 | async def __aenter__(self) -> None: ... 22 | async def __aexit__( 23 | self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None 24 | ) -> None: ... 25 | 26 | class Semaphore: 27 | def __init__( 28 | self, 29 | name: str, 30 | capacity: int, 31 | max_sleep: Optional[float] = None, # Set to 0.0 when None is passed. In seconds. 32 | expiry: Optional[int] = None, # Set to 30 when None is passed. In seconds. 33 | redis_url: Optional[str] = None, # will be set as "redis://127.0.0.1:6379" if None 34 | connection_pool_size: Optional[int] = None, # Will be set to 15 if None 35 | ) -> None: ... 36 | 37 | capacity: int 38 | name: str 39 | max_sleep: float 40 | expiry: int 41 | 42 | async def __aenter__(self) -> None: ... 43 | async def __aexit__( 44 | self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None 45 | ) -> None: ... 46 | 47 | __all__: list[str] 48 | 49 | class RedisError(Exception): 50 | """ 51 | Raised when the downstream redis library raises any exception. 52 | """ 53 | 54 | pass 55 | 56 | class MaxSleepExceededError(Exception): 57 | """ 58 | Raised when we've slept for longer than the `max_sleep` specified limit. 59 | """ 60 | 61 | pass 62 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | asyncio_mode = auto 3 | testpaths = tests 4 | 5 | [flake8] 6 | max-line-length = 120 7 | enable-extensions=TC,TC2 8 | ignore = PT006 9 | per-file-ignores = 10 | .github/*:T201 11 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | extern crate redis; 2 | 3 | use std::io::Error; 4 | use std::sync::mpsc::{RecvError, SendError}; 5 | use std::time::SystemTimeError; 6 | 7 | use bb8_redis::bb8::RunError; 8 | use pyo3::create_exception; 9 | use pyo3::exceptions::{PyException, PyRuntimeError}; 10 | use pyo3::prelude::*; 11 | use redis::RedisError as RedisLibError; 12 | 13 | // Raised when redis::RedisError is raised by the redis crate. 14 | create_exception!(self_limiters, RedisError, PyException); 15 | 16 | // Raised when we've slept for too long. Useful for catching forever-growing queues. 17 | create_exception!(self_limiters, MaxSleepExceededError, PyException); 18 | 19 | /// Enum containing all handled errors. 20 | /// This enables us to use the `?` operator on function calls to utilities 21 | /// that raise any of the mapped errors below, to automatically raise the 22 | /// appropriate mapped Python error. 23 | #[derive(Debug)] 24 | pub(crate) enum SLError { 25 | MaxSleepExceeded(String), 26 | Redis(String), 27 | RuntimeError(String), 28 | } 29 | 30 | // Map relevant error types to appropriate Python exceptions 31 | impl From for PyErr { 32 | fn from(e: SLError) -> Self { 33 | match e { 34 | SLError::MaxSleepExceeded(e) => MaxSleepExceededError::new_err(e), 35 | SLError::Redis(e) => RedisError::new_err(e), 36 | SLError::RuntimeError(e) => PyRuntimeError::new_err(e), 37 | } 38 | } 39 | } 40 | 41 | // redis::RedisError could be raised any time we perform a call to redis 42 | impl From for SLError { 43 | fn from(e: RedisLibError) -> Self { 44 | Self::Redis(e.to_string()) 45 | } 46 | } 47 | 48 | // SendError could be raised when we pass data to a channel 49 | impl From> for SLError { 50 | fn from(e: SendError) -> Self { 51 | Self::RuntimeError(e.to_string()) 52 | } 53 | } 54 | 55 | // RecvError could be raised when we read data from a channel 56 | impl From for SLError { 57 | fn from(e: RecvError) -> Self { 58 | Self::RuntimeError(e.to_string()) 59 | } 60 | } 61 | 62 | // std::io::Error could be raised when we read our Lua scripts 63 | impl From for SLError { 64 | fn from(e: Error) -> Self { 65 | Self::RuntimeError(e.to_string()) 66 | } 67 | } 68 | 69 | // SystemTimeError could be raised when calling SystemTime.now() 70 | impl From for SLError { 71 | fn from(e: SystemTimeError) -> Self { 72 | Self::RuntimeError(e.to_string()) 73 | } 74 | } 75 | 76 | // RunError could happen when creating a connection pool 77 | impl From> for SLError { 78 | fn from(e: RunError) -> Self { 79 | Self::RuntimeError(e.to_string()) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/generated.rs: -------------------------------------------------------------------------------- 1 | /// This file is generated with a build script. 2 | /// 3 | /// Do not make changes to this file. Instead edit the Lua scripts directly. 4 | 5 | pub const SEMAPHORE_SCRIPT: &str = "\ 6 | --- Script called from the Semaphore implementation. 7 | --- 8 | --- Lua scripts are run atomically by default, and since redis 9 | --- is single threaded, there are no race conditions to worry about. 10 | --- 11 | --- The script checks if a list exists for the Semaphore, and 12 | --- creates one of length `capacity` if it doesn't. 13 | --- 14 | --- keys: 15 | --- * key: The key to use for the list 16 | --- * existskey: The key to use for the string we use to check if the lists exists 17 | --- 18 | --- args: 19 | --- * capacity: The capacity of the semaphore (i.e., the length of the list) 20 | --- 21 | --- returns: 22 | --- * 1 if created, else 0 (but the return value isn't used; only useful for debugging) 23 | 24 | redis.replicate_commands() 25 | 26 | -- Init config variables 27 | local key = tostring(KEYS[1]) 28 | local existskey = tostring(KEYS[2]) 29 | local capacity = tonumber(ARGV[1]) 30 | 31 | -- Check if list exists 32 | -- Note, we cannot use EXISTS or LLEN below, as we need 33 | -- to know if a list exists, but has capacity zero. 34 | local does_not_exist = redis.call('SETNX', string.format(existskey, key), 1) 35 | 36 | -- Create the list if none exists 37 | if does_not_exist == 1 then 38 | -- Add '1' as an argument equal to the capacity of the semaphore 39 | -- If capacity is 5 here, we generate `{RPUSH, 1, 1, 1, 1, 1}`. 40 | local args = { 'RPUSH', key } 41 | for _ = 1, capacity do 42 | table.insert(args, 1) 43 | end 44 | redis.call(unpack(args)) 45 | return true 46 | end 47 | return false 48 | "; 49 | pub const TOKEN_BUCKET_SCRIPT: &str = "\ 50 | --- Script called from the Semaphore implementation. 51 | --- partially modelled after https://github.com/Tinche/aiosteady 52 | --- 53 | --- Lua scripts are run atomically by default, and since redis 54 | --- is single threaded, there are no race conditions to worry about. 55 | --- 56 | --- This script does three things, in order: 57 | --- 1. Retrieves token bucket state, which means the last slot assigned, 58 | --- and how many tokens are left to be assigned for that slot 59 | --- 2. Works out whether we need to move to the next slot, or consume another 60 | --- token from the current one. 61 | --- 3. Saves the token bucket state and returns the slot. 62 | --- 63 | --- The token bucket implementation is forward looking, so we're really just handing 64 | --- out the next time there would be tokens in the bucket, and letting the client 65 | --- sleep until then. This would be terrible in a sync application, but for an async 66 | --- python task runner or web-server, it's actually very handy. There is the issue 67 | --- of processes sleeping for an unreasonably long time, but there is a max-sleep 68 | --- setting in both implementations to offset this. 69 | --- 70 | --- keys: 71 | --- * key: The key name to use for the semaphore 72 | --- 73 | --- args: 74 | --- * capacity: The max capacity of the bucket 75 | --- * refill_rate: How often tokens are added to the bucket, (NOTE) in *milliseconds* 76 | --- The rate is in milliseconds since we cannot use floats for the `now` variable. 77 | --- This deviates from the rest of the package code, where the rate is specified in seconds. 78 | --- * refill_amount: How many tokens are added at each interval 79 | --- 80 | --- returns: 81 | --- * The assigned slot, as a millisecond timestamp 82 | 83 | redis.replicate_commands() 84 | 85 | -- Init config variables 86 | local data_key = KEYS[1] 87 | local capacity = tonumber(ARGV[1]) 88 | local refill_rate = tonumber(ARGV[2]) 89 | local refill_amount = tonumber(ARGV[3]) 90 | 91 | -- Get current time (ms timestamp) 92 | local redis_time = redis.call('TIME') -- Array of [seconds, microseconds] 93 | local now = tonumber(redis_time[1]) * 1000 + (tonumber(redis_time[2]) / 1000) 94 | 95 | -- Instantiate default bucket values 96 | -- These are used if no state is retrieved below; i.e., they 97 | -- are the values we use for creating a new bucket. 98 | local tokens = refill_amount 99 | local slot = now + refill_rate 100 | 101 | -- Retrieve (possibly) stored state 102 | local data = redis.call('GET', data_key) 103 | 104 | if data ~= false then 105 | for a, b in string.gmatch(data, '(%S+) (%S+)') do 106 | slot = tonumber(a) 107 | tokens = tonumber(b) 108 | end 109 | 110 | -- Quickly validate our state 111 | 112 | -- If the slot is in the past, we need to increment the slot 113 | -- value, and add tokens to the bucket equal to the slots skipped 114 | if slot < now + 20 then 115 | tokens = tokens + (slot - now) / refill_rate 116 | slot = slot + refill_rate 117 | 118 | -- If we skipped 3 slots, but the capacity is 1, 119 | -- trim the tokens left. 120 | if tokens > capacity then 121 | tokens = capacity 122 | end 123 | end 124 | 125 | -- If the current slot has no more tokens to assign, 126 | -- move to the next slot. 127 | if tokens <= 0 then 128 | slot = slot + refill_rate 129 | tokens = refill_amount 130 | end 131 | end 132 | 133 | -- Consume a token 134 | tokens = tokens - 1 135 | 136 | -- Save state and set expiry 137 | redis.call('SETEX', data_key, 30, string.format('%d %d', slot, tokens)) 138 | 139 | return slot 140 | "; 141 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | extern crate core; 2 | 3 | use pyo3::prelude::*; 4 | 5 | use token_bucket::TokenBucket; 6 | 7 | use crate::errors::{MaxSleepExceededError, RedisError}; 8 | use crate::semaphore::Semaphore; 9 | 10 | mod errors; 11 | mod generated; 12 | mod semaphore; 13 | mod token_bucket; 14 | mod utils; 15 | 16 | #[pymodule] 17 | fn self_limiters(py: Python<'_>, m: &PyModule) -> PyResult<()> { 18 | pyo3_log::init(); 19 | m.add("MaxSleepExceededError", py.get_type::())?; 20 | m.add("RedisError", py.get_type::())?; 21 | m.add_class::()?; 22 | m.add_class::()?; 23 | Ok(()) 24 | } 25 | 26 | #[cfg(test)] 27 | mod tests { 28 | use std::time::Duration; 29 | 30 | use crate::utils::*; 31 | 32 | #[tokio::test] 33 | async fn test_now_millis() -> SLResult<()> { 34 | let now = now_millis()?; 35 | tokio::time::sleep(Duration::from_millis(30)).await; 36 | assert!(now + 30 <= now_millis()?); 37 | assert!(now + 33 >= now_millis()?); 38 | Ok(()) 39 | } 40 | 41 | #[test] 42 | fn test_create_connection_manager() { 43 | // Make sure these normal URLs pass parsing 44 | for good_url in &[ 45 | "redis://127.0.0.1", 46 | "redis://username:@127.0.0.1", 47 | "redis://username:password@127.0.0.1", 48 | "redis://:password@127.0.0.1", 49 | "redis+unix:///127.0.0.1", 50 | "unix:///127.0.0.1", 51 | ] { 52 | for port_postfix in &[":6379", ":1234", ""] { 53 | create_connection_manager(Some(&format!("{}{}", good_url, port_postfix))).unwrap(); 54 | } 55 | } 56 | 57 | // None is also allowed, and we will try to connect to the default address 58 | create_connection_manager(None).unwrap(); 59 | 60 | // Make sure these bad URLs fail 61 | for bad_url in &["", "1", "127.0.0.1:6379", "test://127.0.0.1:6379"] { 62 | if create_connection_manager(Some(bad_url)).is_ok() { 63 | panic!("Should fail") 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/semaphore.rs: -------------------------------------------------------------------------------- 1 | use bb8_redis::bb8::Pool; 2 | use bb8_redis::RedisConnectionManager; 3 | use log::{debug, info}; 4 | use pyo3::prelude::*; 5 | use pyo3::types::PyTuple; 6 | use pyo3_asyncio::tokio::future_into_py; 7 | use redis::{AsyncCommands, Script}; 8 | 9 | use crate::errors::SLError; 10 | use crate::generated::SEMAPHORE_SCRIPT; 11 | use crate::utils::{create_connection_manager, create_connection_pool, now_millis, SLResult, REDIS_KEY_PREFIX}; 12 | 13 | struct ThreadState { 14 | open_connection_pool: Pool, 15 | return_connection_pool: Pool, 16 | name: String, 17 | expiry: usize, 18 | capacity: u32, 19 | max_sleep: f32, 20 | } 21 | 22 | impl ThreadState { 23 | fn from(slf: &Semaphore) -> Self { 24 | Self { 25 | open_connection_pool: slf.open_connection_pool.clone(), 26 | return_connection_pool: slf.return_connection_pool.clone(), 27 | name: slf.name.clone(), 28 | expiry: slf.expiry, 29 | capacity: slf.capacity, 30 | max_sleep: slf.max_sleep, 31 | } 32 | } 33 | 34 | /// Key (re)use in Lua scripts to determine if Semaphore exists or not 35 | fn exists_key(&self) -> String { 36 | format!("{}-exists", self.name) 37 | } 38 | } 39 | 40 | async fn create_and_acquire_semaphore(ts: ThreadState) -> SLResult<()> { 41 | // Connect to redis 42 | let mut connection = ts.open_connection_pool.get().await?; 43 | 44 | // Define queue if it doesn't already exist 45 | if Script::new(SEMAPHORE_SCRIPT) 46 | .key(&ts.name) 47 | .key(&ts.exists_key()) 48 | .arg(ts.capacity) 49 | .invoke_async(&mut *connection) 50 | .await? 51 | { 52 | info!("Created new semaphore queue with a capacity of {}", &ts.capacity); 53 | } else { 54 | debug!("Skipped creating new semaphore queue, since one exists already") 55 | } 56 | 57 | // Wait for our turn - this waits non-blockingly until we're free to proceed 58 | let start = now_millis()?; 59 | connection.blpop(&ts.name, ts.max_sleep as usize).await?; 60 | 61 | // Raise an exception if we waited too long 62 | if ts.max_sleep > 0.0 && (now_millis()? - start) > (ts.max_sleep * 1000.0) as u64 { 63 | return Err(SLError::MaxSleepExceeded( 64 | "Max sleep exceeded waiting for Semaphore".to_string(), 65 | )); 66 | }; 67 | 68 | debug!("Acquired semaphore"); 69 | Ok(()) 70 | } 71 | 72 | async fn release_semaphore(ts: ThreadState) -> SLResult<()> { 73 | // Connect to redis 74 | let mut connection = ts.return_connection_pool.get().await?; 75 | 76 | // Push capacity back to the semaphore 77 | // We don't care about this being atomic 78 | redis::pipe() 79 | .lpush(&ts.name, 1) 80 | .expire(&ts.name, ts.expiry) 81 | .expire(&ts.exists_key(), ts.expiry) 82 | .query_async(&mut *connection) 83 | .await?; 84 | 85 | debug!("Released semaphore"); 86 | Ok(()) 87 | } 88 | 89 | /// Async context manager useful for controlling client traffic 90 | /// in situations where you need to limit traffic to `n` requests concurrently. 91 | /// For example, when you can only have 2 active requests simultaneously. 92 | #[pyclass(frozen)] 93 | #[pyo3(name = "Semaphore")] 94 | #[pyo3(module = "self_limiters")] 95 | pub(crate) struct Semaphore { 96 | #[pyo3(get)] 97 | name: String, 98 | #[pyo3(get)] 99 | capacity: u32, 100 | #[pyo3(get)] 101 | max_sleep: f32, 102 | #[pyo3(get)] 103 | expiry: usize, 104 | open_connection_pool: Pool, 105 | return_connection_pool: Pool, 106 | } 107 | 108 | #[pymethods] 109 | impl Semaphore { 110 | /// Create a new class instance. 111 | #[new] 112 | fn new( 113 | name: String, 114 | capacity: u32, 115 | max_sleep: Option, 116 | expiry: Option, 117 | redis_url: Option<&str>, 118 | connection_pool_size: Option, 119 | ) -> PyResult { 120 | debug!("Creating new Semaphore instance"); 121 | 122 | // Create redis connection manager 123 | let open_manager = create_connection_manager(redis_url)?; 124 | let return_manager = create_connection_manager(redis_url)?; 125 | 126 | // Create connection pool 127 | let open_pool = create_connection_pool(open_manager, connection_pool_size.unwrap_or(15))?; 128 | let return_pool = create_connection_pool(return_manager, connection_pool_size.unwrap_or(15))?; 129 | 130 | Ok(Self { 131 | capacity, 132 | name: format!("{}{}", REDIS_KEY_PREFIX, name), 133 | max_sleep: max_sleep.unwrap_or(0.0), 134 | expiry: expiry.unwrap_or(30), 135 | open_connection_pool: open_pool, 136 | return_connection_pool: return_pool, 137 | }) 138 | } 139 | 140 | fn __aenter__<'p>(&self, py: Python<'p>) -> PyResult<&'p PyAny> { 141 | let ts = ThreadState::from(self); 142 | future_into_py(py, async { Ok(create_and_acquire_semaphore(ts).await?) }) 143 | } 144 | 145 | #[args(_a = "*")] 146 | fn __aexit__<'p>(&self, py: Python<'p>, _a: &'p PyTuple) -> PyResult<&'p PyAny> { 147 | let ts = ThreadState::from(self); 148 | future_into_py(py, async { Ok(release_semaphore(ts).await?) }) 149 | } 150 | 151 | fn __repr__(&self) -> String { 152 | format!("Semaphore instance for queue {}", &self.name) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/token_bucket.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use bb8_redis::bb8::Pool; 4 | use bb8_redis::RedisConnectionManager; 5 | use log::debug; 6 | use pyo3::exceptions::PyValueError; 7 | use pyo3::prelude::*; 8 | use pyo3::types::PyTuple; 9 | use pyo3::{PyAny, PyResult, Python}; 10 | use pyo3_asyncio::tokio::future_into_py; 11 | use redis::Script; 12 | 13 | use crate::errors::SLError; 14 | use crate::generated::TOKEN_BUCKET_SCRIPT; 15 | use crate::utils::{create_connection_manager, create_connection_pool, now_millis, SLResult, REDIS_KEY_PREFIX}; 16 | 17 | struct ThreadState { 18 | capacity: u32, 19 | frequency: f32, 20 | amount: u32, 21 | max_sleep: f32, 22 | connection_pool: Pool, 23 | name: String, 24 | } 25 | 26 | impl ThreadState { 27 | fn from(slf: &TokenBucket) -> Self { 28 | Self { 29 | capacity: slf.capacity, 30 | frequency: slf.refill_frequency, 31 | amount: slf.refill_amount, 32 | max_sleep: slf.max_sleep, 33 | connection_pool: slf.connection_pool.clone(), 34 | name: slf.name.clone(), 35 | } 36 | } 37 | } 38 | 39 | async fn schedule_and_sleep(ts: ThreadState) -> SLResult<()> { 40 | // Connect to redis 41 | let mut connection = ts.connection_pool.get().await?; 42 | 43 | // Retrieve slot 44 | let slot: u64 = Script::new(TOKEN_BUCKET_SCRIPT) 45 | .key(&ts.name) 46 | .arg(ts.capacity) 47 | .arg(ts.frequency * 1000.0) // in ms 48 | .arg(ts.amount) 49 | .invoke_async(&mut *connection) 50 | .await?; 51 | 52 | let now = now_millis()?; 53 | let sleep_duration = { 54 | // This might happen at very low refill frequencies. 55 | // Current handling isn't robust enough to ensure 56 | // exactly uniform traffic when this happens. Might be 57 | // something worth looking at more in the future, if needed. 58 | if slot <= now { 59 | Duration::from_millis(0) 60 | } else { 61 | Duration::from_millis(slot - now) 62 | } 63 | }; 64 | 65 | if ts.max_sleep > 0.0 && sleep_duration > Duration::from_secs_f32(ts.max_sleep) { 66 | return Err(SLError::MaxSleepExceeded(format!( 67 | "Received wake up time in {} seconds, which is \ 68 | greater or equal to the specified max sleep of {} seconds", 69 | sleep_duration.as_secs(), 70 | ts.max_sleep 71 | ))); 72 | } 73 | 74 | debug!("Retrieved slot. Sleeping for {}.", sleep_duration.as_secs_f32()); 75 | tokio::time::sleep(sleep_duration).await; 76 | 77 | Ok(()) 78 | } 79 | 80 | /// Async context manager useful for controlling client traffic 81 | /// in situations where you need to limit traffic to `n` requests per `m` unit of time. 82 | /// For example, when you can only send 1 request per minute. 83 | #[pyclass(frozen)] 84 | #[pyo3(name = "TokenBucket")] 85 | #[pyo3(module = "self_limiters")] 86 | pub(crate) struct TokenBucket { 87 | #[pyo3(get)] 88 | capacity: u32, 89 | #[pyo3(get)] 90 | refill_frequency: f32, 91 | #[pyo3(get)] 92 | refill_amount: u32, 93 | #[pyo3(get)] 94 | name: String, 95 | max_sleep: f32, 96 | connection_pool: Pool, 97 | } 98 | 99 | #[pymethods] 100 | impl TokenBucket { 101 | /// Create a new class instance. 102 | #[new] 103 | fn new( 104 | name: String, 105 | capacity: u32, 106 | refill_frequency: f32, 107 | refill_amount: u32, 108 | redis_url: Option<&str>, 109 | max_sleep: Option, 110 | connection_pool_size: Option, 111 | ) -> PyResult { 112 | debug!("Creating new TokenBucket instance"); 113 | 114 | if refill_frequency <= 0.0 { 115 | return Err(PyValueError::new_err("Refill frequency must be greater than 0")); 116 | } 117 | // Create redis connection manager 118 | let manager = create_connection_manager(redis_url)?; 119 | 120 | // Create connection pool 121 | let pool = create_connection_pool(manager, connection_pool_size.unwrap_or(30))?; 122 | 123 | Ok(Self { 124 | capacity, 125 | refill_amount, 126 | refill_frequency, 127 | max_sleep: max_sleep.unwrap_or(0.0), 128 | name: format!("{}{}", REDIS_KEY_PREFIX, name), 129 | connection_pool: pool, 130 | }) 131 | } 132 | 133 | /// Spawn a scheduler thread to schedule wake-up times for nodes, 134 | /// and let the main thread wait for assignment of wake-up time 135 | /// then sleep until ready. 136 | fn __aenter__<'p>(&self, py: Python<'p>) -> PyResult<&'p PyAny> { 137 | let ts = ThreadState::from(self); 138 | future_into_py(py, async { Ok(schedule_and_sleep(ts).await?) }) 139 | } 140 | 141 | /// Do nothing on aexit. 142 | #[args(_a = "*")] 143 | fn __aexit__<'p>(&self, py: Python<'p>, _a: &'p PyTuple) -> PyResult<&'p PyAny> { 144 | future_into_py(py, async { Ok(()) }) 145 | } 146 | 147 | fn __repr__(&self) -> String { 148 | format!("Token bucket instance for queue {}", &self.name) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::time::{SystemTime, UNIX_EPOCH}; 2 | 3 | use bb8_redis::bb8::Pool; 4 | use bb8_redis::RedisConnectionManager; 5 | use log::info; 6 | use redis::parse_redis_url; 7 | 8 | use crate::errors::SLError; 9 | 10 | pub(crate) type SLResult = Result; 11 | pub(crate) const REDIS_DEFAULT_URL: &str = "redis://127.0.0.1:6379"; 12 | pub(crate) const REDIS_KEY_PREFIX: &str = "__self-limiters:"; 13 | 14 | pub(crate) fn now_millis() -> SLResult { 15 | // Beware: This will overflow in 500 thousand years 16 | Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as u64) 17 | } 18 | 19 | pub(crate) fn create_connection_manager(redis_url: Option<&str>) -> SLResult { 20 | match parse_redis_url(redis_url.unwrap_or(REDIS_DEFAULT_URL)) { 21 | Some(url) => match RedisConnectionManager::new(url) { 22 | Ok(manager) => Ok(manager), 23 | Err(e) => Err(SLError::Redis(format!( 24 | "Failed to open redis connection manager: {}", 25 | e 26 | ))), 27 | }, 28 | None => Err(SLError::Redis(String::from("Failed to parse redis url"))), 29 | } 30 | } 31 | 32 | pub(crate) fn create_connection_pool( 33 | manager: RedisConnectionManager, 34 | max_size: u32, 35 | ) -> SLResult> { 36 | let future = async move { Pool::builder().max_size(max_size).build(manager).await.unwrap() }; 37 | let res = tokio::runtime::Builder::new_current_thread() 38 | .enable_all() 39 | .build() 40 | .unwrap() 41 | .block_on(future); 42 | info!("Created connection pool of max {} connections", max_size); 43 | Ok(res) 44 | } 45 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/self-limiters/28092ab71d71c1686826c02c14b464b2fcee367e/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from functools import partial 4 | from pathlib import Path 5 | from typing import TYPE_CHECKING 6 | from uuid import uuid4 7 | 8 | from self_limiters import Semaphore, TokenBucket 9 | 10 | if TYPE_CHECKING: 11 | from datetime import timedelta 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | REPO_ROOT = Path(__file__).parent.parent 16 | 17 | 18 | def semaphore_factory(**kwargs) -> partial: 19 | """ 20 | Provide an almost initialized semaphore with defaults. 21 | 22 | This makes it easy to init semaphores with slightly different configurations in tests. 23 | """ 24 | 25 | defaults = {'name': uuid4().hex[:6], 'capacity': 1, 'redis_url': 'redis://127.0.0.1:6389'} 26 | return partial(Semaphore, **{**defaults, **kwargs}) 27 | 28 | 29 | def tokenbucket_factory(**kwargs) -> partial: 30 | """ 31 | Provide an almost initialized token bucket with defaults. 32 | 33 | This makes it easy to init token buckets with slightly different configurations in tests. 34 | """ 35 | 36 | defaults = { 37 | 'name': uuid4().hex[:6], 38 | 'capacity': 1, 39 | 'refill_frequency': 1.0, 40 | 'refill_amount': 1, 41 | 'redis_url': 'redis://127.0.0.1:6389', 42 | } 43 | return partial(TokenBucket, **{**defaults, **kwargs}) 44 | 45 | 46 | def delta_to_seconds(t: 'timedelta') -> float: 47 | return t.seconds + t.microseconds / 1_000_000 48 | 49 | 50 | async def run(pt: partial, duration: float) -> None: 51 | async with pt(): 52 | logger.info(f'Sleeping {duration}') 53 | await asyncio.sleep(duration) 54 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from uuid import uuid4 4 | 5 | import pytest 6 | from redis.asyncio.client import Redis 7 | from self_limiters import RedisError 8 | 9 | from .conftest import run, semaphore_factory, tokenbucket_factory 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | @pytest.mark.parametrize('limiter', [semaphore_factory(redis_url='test'), tokenbucket_factory(redis_url='test')]) 15 | async def test_redis_error_on_bad_connection_string(limiter): 16 | """ 17 | Redis errors should propagate as self_limiters.RedisError. 18 | """ 19 | with pytest.raises(RedisError): 20 | await run(limiter, 0) 21 | 22 | 23 | async def test_redis_error(): 24 | """ 25 | Trigger the equivalent of a runtime error in Redis, 26 | to ensure that these types of redis errors are also 27 | propagated correctly. 28 | """ 29 | name = f'error-test-{uuid4()}' 30 | queue_name = f'__self-limiters:{name}' 31 | 32 | async def corrupt_queue(): 33 | # Wait 0.1 seconds then destroy the queue 34 | await asyncio.sleep(0.3) 35 | r = Redis.from_url('redis://127.0.0.1:6389') 36 | await r.delete(queue_name) 37 | await r.set(queue_name, 'test') 38 | 39 | tasks = [asyncio.create_task(corrupt_queue())] 40 | tasks += [asyncio.create_task(run(semaphore_factory(name=name), 0.1)) for _ in range(10)] 41 | 42 | with pytest.raises(RedisError): 43 | await asyncio.gather(*tasks) 44 | -------------------------------------------------------------------------------- /tests/test_semaphore.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import re 4 | from datetime import datetime 5 | from uuid import uuid4 6 | 7 | import pytest 8 | from redis.asyncio.client import Monitor, Redis 9 | from self_limiters import MaxSleepExceededError, Semaphore 10 | 11 | from .conftest import delta_to_seconds, run, semaphore_factory 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | @pytest.mark.parametrize( 17 | 'n, capacity, sleep, timeout', 18 | [ 19 | (10, 1, 0.1, 1), 20 | (10, 2, 0.1, 0.5), 21 | (10, 10, 0.1, 0.1), 22 | (5, 1, 0.1, 0.5), 23 | ], 24 | ) 25 | async def test_semaphore_runtimes(n, capacity, sleep, timeout): 26 | """ 27 | Make sure that the runtime of multiple Semaphore instances conform to our expectations. 28 | 29 | The runtime should never fall below the expected lower bound. If we run 6 instances for 30 | a Semaphore with a capacity of 5, where each instance sleeps 1 second, then it should 31 | always take 1 >= seconds to run those. 32 | """ 33 | name = f'runtimes-{uuid4()}' 34 | tasks = [ 35 | asyncio.create_task(run(semaphore_factory(name=name, capacity=capacity), duration=sleep)) for _ in range(n) 36 | ] 37 | 38 | before = datetime.now() 39 | await asyncio.gather(*tasks) 40 | assert timeout <= delta_to_seconds(datetime.now() - before) 41 | 42 | 43 | async def test_sleep_is_non_blocking(): 44 | async def _sleep(duration: float) -> None: 45 | await asyncio.sleep(duration) 46 | 47 | tasks = [ 48 | # Create task for semaphore to sleep 1 second 49 | asyncio.create_task(run(semaphore_factory(), 0)), 50 | # And create another task to normal asyncio sleep for 1 second 51 | asyncio.create_task(_sleep(1)), 52 | ] 53 | 54 | # Both tasks should complete in ~1 second if thing are working correctly 55 | await asyncio.wait_for(asyncio.gather(*tasks), 1.05) 56 | 57 | 58 | def test_class_attributes(): 59 | """Check attributes are readable, but immutable.""" 60 | semaphore = Semaphore(name='test', capacity=1) 61 | 62 | assert semaphore.name == '__self-limiters:test' 63 | assert semaphore.capacity == 1 64 | assert semaphore.max_sleep == 0 65 | 66 | with pytest.raises(AttributeError, match="attribute 'name' of 'self_limiters.Semaphore' objects is not writable"): 67 | semaphore.name = 'test2' 68 | 69 | 70 | def test_repr(): 71 | semaphore = Semaphore(name='test', capacity=1) 72 | assert re.match(r'Semaphore instance for queue __self-limiters:test', str(semaphore)) # noqa: W605 73 | 74 | 75 | @pytest.mark.parametrize( 76 | 'config,e', 77 | [ 78 | ({'name': ''}, None), 79 | ({'name': None}, TypeError), 80 | ({'name': 1}, TypeError), 81 | ({'name': True}, TypeError), 82 | ({'capacity': 2}, None), 83 | ({'capacity': 2.2}, TypeError), 84 | ({'capacity': None}, TypeError), 85 | ({'capacity': 'test'}, TypeError), 86 | ({'redis_url': 'redis://a.b'}, None), 87 | ({'redis_url': 1}, TypeError), 88 | ({'redis_url': True}, TypeError), 89 | ({'max_sleep': 20}, None), 90 | ({'max_sleep': 0}, None), 91 | ({'max_sleep': 'test'}, TypeError), 92 | ({'max_sleep': None}, None), 93 | ], 94 | ) 95 | def test_init_types(config, e): 96 | if e: 97 | with pytest.raises(e): 98 | semaphore_factory(**config)() 99 | else: 100 | semaphore_factory(**config)() 101 | 102 | 103 | @pytest.mark.filterwarnings('ignore::RuntimeWarning') 104 | async def test_max_sleep(): 105 | name = uuid4().hex[:6] 106 | with pytest.raises(MaxSleepExceededError, match='Max sleep exceeded waiting for Semaphore'): 107 | await asyncio.gather( 108 | *[asyncio.create_task(run(semaphore_factory(name=name, max_sleep=1), 1)) for _ in range(3)] 109 | ) 110 | 111 | 112 | async def test_redis_instructions(): 113 | r = Redis.from_url('redis://127.0.0.1:6389') 114 | name = uuid4().hex 115 | 116 | m: Monitor 117 | async with r.monitor() as m: 118 | await m.connect() 119 | await run(semaphore_factory(name=name, expiry=1), 0) 120 | 121 | # We expect the eval to generate 7 calls 122 | commands = [ 123 | # EVALSHA 124 | str(await m.connection.read_response()), 125 | # SETNX 126 | str(await m.connection.read_response()), 127 | # RPUSH 128 | str(await m.connection.read_response()), 129 | # BLPOP 130 | str(await m.connection.read_response()), 131 | # LPUSH 132 | str(await m.connection.read_response()), 133 | # EXPIRE 134 | str(await m.connection.read_response()), 135 | # EXPIRE 136 | str(await m.connection.read_response()), 137 | ] 138 | 139 | # Make sure there are no other commands generated 140 | with pytest.raises(asyncio.TimeoutError): 141 | # This will time out if there are no other commands 142 | await asyncio.wait_for(timeout=1, fut=m.connection.read_response()) 143 | 144 | # Make sure each command conforms to our expectations 145 | assert 'EVALSHA' in commands[0] 146 | assert 'SETNX' in commands[1] 147 | assert f'__self-limiters:{name}-exists' in commands[1] 148 | assert 'RPUSH' in commands[2] 149 | assert f'__self-limiters:{name}' in commands[2] 150 | assert 'BLPOP' in commands[3] 151 | assert 'LPUSH' in commands[4] 152 | assert 'EXPIRE' in commands[5] 153 | assert f'__self-limiters:{name}' in commands[5] 154 | assert 'EXPIRE' in commands[6] 155 | assert f'__self-limiters:{name}-exists' in commands[6] 156 | -------------------------------------------------------------------------------- /tests/test_token_bucket.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import re 4 | from datetime import datetime 5 | from uuid import uuid4 6 | 7 | import pytest 8 | from self_limiters import MaxSleepExceededError 9 | 10 | from .conftest import delta_to_seconds, run, tokenbucket_factory 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | @pytest.mark.parametrize( 16 | 'n, frequency, timeout', 17 | [ 18 | (10, 0.1, 1), 19 | (10, 0.01, 0.1), 20 | (2, 1, 2), 21 | ], 22 | ) 23 | async def test_token_bucket_runtimes(n, frequency, timeout): 24 | # Ensure n tasks never complete in less than n/(refill_frequency * refill_amount) 25 | name = f'runtimes-{uuid4()}' 26 | tasks = [ 27 | asyncio.create_task(run(tokenbucket_factory(name=name, capacity=1, refill_frequency=frequency), duration=0)) 28 | for _ in range(n) 29 | ] 30 | 31 | before = datetime.now() 32 | await asyncio.gather(*tasks) 33 | assert timeout <= delta_to_seconds(datetime.now() - before) 34 | 35 | 36 | async def test_sleep_is_non_blocking(): 37 | async def _sleep(duration: float) -> None: 38 | await asyncio.sleep(duration) 39 | 40 | tasks = [ 41 | # Create task for token bucket to sleep 1 second 42 | # And create other tasks to normal asyncio sleep for 1 second 43 | asyncio.create_task(_sleep(1)), 44 | asyncio.create_task(run(tokenbucket_factory(), 0)), 45 | asyncio.create_task(_sleep(1)), 46 | asyncio.create_task(run(tokenbucket_factory(), 0)), 47 | ] 48 | 49 | # Both tasks should complete in ~1 second if thing are working correctly 50 | await asyncio.wait_for(timeout=1.1, fut=asyncio.gather(*tasks)) 51 | 52 | 53 | def test_class_attributes(): 54 | """ 55 | Check attributes are accessible, and check defaults. 56 | """ 57 | tb = tokenbucket_factory(name='test', capacity=1)() 58 | assert tb.name 59 | assert tb.capacity == 1 60 | assert tb.refill_frequency == 1.0 61 | assert tb.refill_amount == 1 62 | 63 | with pytest.raises( 64 | AttributeError, match="attribute 'refill_amount' of 'self_limiters.TokenBucket' objects is not writable" 65 | ): 66 | tb.refill_amount = 'test2' 67 | 68 | 69 | def test_repr(): 70 | tb = tokenbucket_factory(name='test', capacity=1)() 71 | assert re.match(r'Token bucket instance for queue __self-limiters:test', str(tb)) # noqa: W605 72 | 73 | 74 | @pytest.mark.parametrize( 75 | 'config,e', 76 | [ 77 | ({'name': ''}, None), 78 | ({'name': None}, TypeError), 79 | ({'name': 1}, TypeError), 80 | ({'name': True}, TypeError), 81 | ({'capacity': 2}, None), 82 | ({'capacity': 2.2}, TypeError), 83 | ({'capacity': -1}, OverflowError), 84 | ({'capacity': None}, TypeError), 85 | ({'capacity': 'test'}, TypeError), 86 | ({'refill_frequency': 2.2}, None), 87 | ({'refill_frequency': 'test'}, TypeError), 88 | ({'refill_frequency': None}, TypeError), 89 | ({'refill_frequency': -1}, ValueError), 90 | ({'refill_amount': 1}, None), 91 | ({'refill_amount': -1}, OverflowError), 92 | ({'refill_amount': 'test'}, TypeError), 93 | ({'refill_amount': None}, TypeError), 94 | ({'redis_url': 'redis://a.b'}, None), 95 | ({'redis_url': 1}, TypeError), 96 | ({'redis_url': True}, TypeError), 97 | ({'max_sleep': 20}, None), 98 | ({'max_sleep': 0}, None), 99 | ({'max_sleep': 'test'}, TypeError), 100 | ({'max_sleep': None}, None), 101 | ], 102 | ) 103 | def test_init_types(config, e): 104 | if e: 105 | with pytest.raises(e): 106 | tokenbucket_factory(**config)() 107 | else: 108 | tokenbucket_factory(**config)() 109 | 110 | 111 | async def test_max_sleep(): 112 | name = uuid4().hex[:6] 113 | e = 'Received wake up time in [0-9] seconds, which is greater or equal to the specified max sleep of 1 seconds' 114 | with pytest.raises(MaxSleepExceededError, match=e): 115 | await asyncio.gather( 116 | *[asyncio.create_task(run(tokenbucket_factory(name=name, max_sleep=1), 0)) for _ in range(10)] 117 | ) 118 | --------------------------------------------------------------------------------