mirror of
https://github.com/python-telegram-bot/python-telegram-bot.git
synced 2025-03-27 08:50:38 +01:00
New Rate Limiting Mechanism (#3148)
This commit is contained in:
parent
cf6c298b82
commit
741a50ab97
30 changed files with 3643 additions and 60 deletions
2
.github/CONTRIBUTING.rst
vendored
2
.github/CONTRIBUTING.rst
vendored
|
@ -26,7 +26,7 @@ Setting things up
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
$ pip install -r requirements.txt -r requirements-dev.txt
|
$ pip install -r requirements-all.txt
|
||||||
|
|
||||||
|
|
||||||
5. Install pre-commit hooks:
|
5. Install pre-commit hooks:
|
||||||
|
|
4
.github/workflows/docs-linkcheck.yml
vendored
4
.github/workflows/docs-linkcheck.yml
vendored
|
@ -22,8 +22,6 @@ jobs:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -W ignore -m pip install --upgrade pip
|
python -W ignore -m pip install --upgrade pip
|
||||||
python -W ignore -m pip install -r requirements.txt
|
python -W ignore -m pip install -r requirements-all.txt
|
||||||
python -W ignore -m pip install -r requirements-dev.txt
|
|
||||||
python -W ignore -m pip install -r docs/requirements-docs.txt
|
|
||||||
- name: Check Links
|
- name: Check Links
|
||||||
run: sphinx-build docs/source docs/build/html -W --keep-going -j auto -b linkcheck
|
run: sphinx-build docs/source docs/build/html -W --keep-going -j auto -b linkcheck
|
||||||
|
|
4
.github/workflows/docs.yml
vendored
4
.github/workflows/docs.yml
vendored
|
@ -27,8 +27,6 @@ jobs:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -W ignore -m pip install --upgrade pip
|
python -W ignore -m pip install --upgrade pip
|
||||||
python -W ignore -m pip install -r requirements.txt
|
python -W ignore -m pip install -r requirements-all.txt
|
||||||
python -W ignore -m pip install -r requirements-dev.txt
|
|
||||||
python -W ignore -m pip install -r docs/requirements-docs.txt
|
|
||||||
- name: Build docs
|
- name: Build docs
|
||||||
run: sphinx-build docs/source docs/build/html -W --keep-going -j auto
|
run: sphinx-build docs/source docs/build/html -W --keep-going -j auto
|
||||||
|
|
|
@ -3,6 +3,7 @@ on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
paths:
|
paths:
|
||||||
- requirements.txt
|
- requirements.txt
|
||||||
|
- requirements-opts.txt
|
||||||
- .pre-commit-config.yaml
|
- .pre-commit-config.yaml
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
@ -14,5 +15,5 @@ jobs:
|
||||||
- name: running the check
|
- name: running the check
|
||||||
uses: Poolitzer/notifier-action@master
|
uses: Poolitzer/notifier-action@master
|
||||||
with:
|
with:
|
||||||
notify-message: Hey! Looks like you edited the requirements or the pre-commit hooks. I'm just a friendly reminder to keep the additional dependencies for the hooks in sync with the requirements :)
|
notify-message: Hey! Looks like you edited the (optional) requirements or the pre-commit hooks. I'm just a friendly reminder to keep the additional dependencies for the hooks in sync with the requirements :)
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
22
.github/workflows/test.yml
vendored
22
.github/workflows/test.yml
vendored
|
@ -42,32 +42,41 @@ jobs:
|
||||||
python -W ignore -m pip install --upgrade pip
|
python -W ignore -m pip install --upgrade pip
|
||||||
python -W ignore -m pip install -U codecov pytest-cov
|
python -W ignore -m pip install -U codecov pytest-cov
|
||||||
python -W ignore -m pip install -r requirements.txt
|
python -W ignore -m pip install -r requirements.txt
|
||||||
|
python -W ignore -m pip install -r requirements-opts.txt
|
||||||
python -W ignore -m pip install -r requirements-dev.txt
|
python -W ignore -m pip install -r requirements-dev.txt
|
||||||
|
|
||||||
- name: Test with pytest
|
- name: Test with pytest
|
||||||
# We run 3 different suites here
|
# We run 4 different suites here
|
||||||
# 1. Test just utils.datetime.py without pytz being installed
|
# 1. Test just utils.datetime.py without pytz being installed
|
||||||
# 2. Test just test_no_passport.py without passport dependencies being installed
|
# 2. Test just test_no_passport.py without passport dependencies being installed
|
||||||
# 3. Test everything else
|
# 3. Test just test_rate_limiter.py without passport dependencies being installed
|
||||||
|
# 4. Test everything else
|
||||||
# The first & second one are achieved by mocking the corresponding import
|
# The first & second one are achieved by mocking the corresponding import
|
||||||
# See test_helpers.py & test_no_passport.py for details
|
# See test_helpers.py & test_no_passport.py for details
|
||||||
run: |
|
run: |
|
||||||
pytest -v --cov -k test_no_passport.py
|
pytest -v --cov -k test_no_passport.py
|
||||||
no_passport_exit=$?
|
no_passport_exit=$?
|
||||||
export TEST_NO_PASSPORT='false'
|
export TEST_PASSPORT='true'
|
||||||
pytest -v --cov --cov-append -k test_helpers.py
|
pytest -v --cov --cov-append -k test_helpers.py
|
||||||
no_pytz_exit=$?
|
no_pytz_exit=$?
|
||||||
export TEST_NO_PYTZ='false'
|
export TEST_PYTZ='true'
|
||||||
|
pip uninstall aiolimiter -y
|
||||||
|
pytest -v --cov --cov-append -k test_ratelimiter.py
|
||||||
|
no_rate_limiter_exit=$?
|
||||||
|
export TEST_RATE_LIMITER='true'
|
||||||
|
pip install -r requirements-opts.txt
|
||||||
pytest -v --cov --cov-append
|
pytest -v --cov --cov-append
|
||||||
full_exit=$?
|
full_exit=$?
|
||||||
special_exit=$(( no_pytz_exit > no_passport_exit ? no_pytz_exit : no_passport_exit ))
|
special_exit=$(( no_pytz_exit > no_passport_exit ? no_pytz_exit : no_passport_exit ))
|
||||||
|
special_exit=$(( special_exit > no_rate_limiter_exit ? special_exit : no_rate_limiter_exit ))
|
||||||
global_exit=$(( special_exit > full_exit ? special_exit : full_exit ))
|
global_exit=$(( special_exit > full_exit ? special_exit : full_exit ))
|
||||||
exit ${global_exit}
|
exit ${global_exit}
|
||||||
env:
|
env:
|
||||||
JOB_INDEX: ${{ strategy.job-index }}
|
JOB_INDEX: ${{ strategy.job-index }}
|
||||||
BOTS: W3sidG9rZW4iOiAiNjk2MTg4NzMyOkFBR1Z3RUtmSEhsTmpzY3hFRE5LQXdraEdzdFpfa28xbUMwIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6WldGaU1UUmxNbVF5TnpNeSIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gVHJhdmlzIHVzaW5nIENQeXRob24gMi43IiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxMzkwOTgzOTk3IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX3RyYXZpc19jcHl0aG9uXzI3X2JvdCJ9LCB7InRva2VuIjogIjY3MTQ2ODg4NjpBQUdQR2ZjaVJJQlVORmU4MjR1SVZkcTdKZTNfWW5BVE5HdyIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOlpHWXdPVGxrTXpNeE4yWTIiLCAiYm90X25hbWUiOiAiUFRCIHRlc3RzIG9uIFRyYXZpcyB1c2luZyBDUHl0aG9uIDMuNCIsICJzdXBlcl9ncm91cF9pZCI6ICItMTAwMTQ0NjAyMjUyMiIsICJib3RfdXNlcm5hbWUiOiAiQHB0Yl90cmF2aXNfY3B5dGhvbl8zNF9ib3QifSwgeyJ0b2tlbiI6ICI2MjkzMjY1Mzg6QUFGUnJaSnJCN29CM211ekdzR0pYVXZHRTVDUXpNNUNVNG8iLCAicGF5bWVudF9wcm92aWRlcl90b2tlbiI6ICIyODQ2ODUwNjM6VEVTVDpNbU01WVdKaFl6a3hNMlUxIiwgImJvdF9uYW1lIjogIlBUQiB0ZXN0cyBvbiBUcmF2aXMgdXNpbmcgQ1B5dGhvbiAzLjUiLCAic3VwZXJfZ3JvdXBfaWQiOiAiLTEwMDE0OTY5MTc3NTAiLCAiYm90X3VzZXJuYW1lIjogIkBwdGJfdHJhdmlzX2NweXRob25fMzVfYm90In0sIHsidG9rZW4iOiAiNjQwMjA4OTQzOkFBRmhCalFwOXFtM1JUeFN6VXBZekJRakNsZS1Kano1aGNrIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6WXpoa1pUZzFOamMxWXpWbCIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gVHJhdmlzIHVzaW5nIENQeXRob24gMy42IiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxMzMzODcxNDYxIiwgImJvdF91c2VybmFtZSI6ICJAcHRiX3RyYXZpc19jcHl0aG9uXzM2X2JvdCJ9LCB7InRva2VuIjogIjY5NTEwNDA4ODpBQUhmenlsSU9qU0lJUy1lT25JMjB5MkUyMEhvZEhzZnotMCIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOk9HUTFNRGd3WmpJd1pqRmwiLCAiYm90X25hbWUiOiAiUFRCIHRlc3RzIG9uIFRyYXZpcyB1c2luZyBDUHl0aG9uIDMuNyIsICJzdXBlcl9ncm91cF9pZCI6ICItMTAwMTQ3ODI5MzcxNCIsICJib3RfdXNlcm5hbWUiOiAiQHB0Yl90cmF2aXNfY3B5dGhvbl8zN19ib3QifSwgeyJ0b2tlbiI6ICI2OTE0MjM1NTQ6QUFGOFdrakNaYm5IcVBfaTZHaFRZaXJGRWxackdhWU9oWDAiLCAicGF5bWVudF9wcm92aWRlcl90b2tlbiI6ICIyODQ2ODUwNjM6VEVTVDpZamM1TlRoaU1tUXlNV1ZoIiwgImJvdF9uYW1lIjogIlBUQiB0ZXN0cyBvbiBUcmF2aXMgdXNpbmcgUHlQeSAyLjciLCAic3VwZXJfZ3JvdXBfaWQiOiAiLTEwMDEzNjM5MzI1NzMiLCAiYm90X3VzZXJuYW1lIjogIkBwdGJfdHJhdmlzX3B5cHlfMjdfYm90In0sIHsidG9rZW4iOiAiNjg0MzM5OTg0OkFBRk1nRUVqcDAxcjVyQjAwN3lDZFZOc2c4QWxOc2FVLWNjIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6TVRBek1UWTNNR1V5TmpnMCIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gVHJhdmlzIHVzaW5nIFB5UHkgMy41IiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxNDA3ODM2NjA1IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX3RyYXZpc19weXB5XzM1X2JvdCJ9LCB7InRva2VuIjogIjY5MDA5MTM0NzpBQUZMbVI1cEFCNVljcGVfbU9oN3pNNEpGQk9oMHozVDBUbyIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOlpEaGxOekU1TURrd1lXSmkiLCAiYm90X25hbWUiOiAiUFRCIHRlc3RzIG9uIEFwcFZleW9yIHVzaW5nIENQeXRob24gMy40IiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxMjc5NjAwMDI2IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX2FwcHZleW9yX2NweXRob25fMzRfYm90In0sIHsidG9rZW4iOiAiNjk0MzA4MDUyOkFBRUIyX3NvbkNrNTVMWTlCRzlBTy1IOGp4aVBTNTVvb0JBIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6WW1aaVlXWm1NakpoWkdNeSIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gQXBwVmV5b3IgdXNpbmcgQ1B5dGhvbiAyLjciLCAic3VwZXJfZ3JvdXBfaWQiOiAiLTEwMDEyOTMwNzkxNjUiLCAiYm90X3VzZXJuYW1lIjogIkBwdGJfYXBwdmV5b3JfY3B5dGhvbl8yN19ib3QifSwgeyJ0b2tlbiI6ICIxMDU1Mzk3NDcxOkFBRzE4bkJfUzJXQXd1SjNnN29oS0JWZ1hYY2VNbklPeVNjIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6TmpBd056QXpZalZpTkdOayIsICJuYW1lIjogIlBUQiB0ZXN0cyBbMF0iLCAic3VwZXJfZ3JvdXBfaWQiOiAiLTEwMDExODU1MDk2MzYiLCAidXNlcm5hbWUiOiAicHRiXzBfYm90In0sIHsidG9rZW4iOiAiMTA0NzMyNjc3MTpBQUY4bk90ODFGcFg4bGJidno4VWV3UVF2UmZUYkZmQnZ1SSIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOllUVTFOVEk0WkdSallqbGkiLCAibmFtZSI6ICJQVEIgdGVzdHMgWzFdIiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxNDg0Nzk3NjEyIiwgInVzZXJuYW1lIjogInB0Yl8xX2JvdCJ9LCB7InRva2VuIjogIjk3MTk5Mjc0NTpBQUdPa09hVzBOSGpnSXY1LTlqUWJPajR2R3FkaFNGLVV1cyIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOk5XWmtNV1ZoWWpsallqVTUiLCAibmFtZSI6ICJQVEIgdGVzdHMgWzJdIiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxNDAyMjU1MDcwIiwgInVzZXJuYW1lIjogInB0Yl8yX2JvdCJ9XQ==
|
BOTS: W3sidG9rZW4iOiAiNjk2MTg4NzMyOkFBR1Z3RUtmSEhsTmpzY3hFRE5LQXdraEdzdFpfa28xbUMwIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6WldGaU1UUmxNbVF5TnpNeSIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gVHJhdmlzIHVzaW5nIENQeXRob24gMi43IiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxMzkwOTgzOTk3IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX3RyYXZpc19jcHl0aG9uXzI3X2JvdCJ9LCB7InRva2VuIjogIjY3MTQ2ODg4NjpBQUdQR2ZjaVJJQlVORmU4MjR1SVZkcTdKZTNfWW5BVE5HdyIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOlpHWXdPVGxrTXpNeE4yWTIiLCAiYm90X25hbWUiOiAiUFRCIHRlc3RzIG9uIFRyYXZpcyB1c2luZyBDUHl0aG9uIDMuNCIsICJzdXBlcl9ncm91cF9pZCI6ICItMTAwMTQ0NjAyMjUyMiIsICJib3RfdXNlcm5hbWUiOiAiQHB0Yl90cmF2aXNfY3B5dGhvbl8zNF9ib3QifSwgeyJ0b2tlbiI6ICI2MjkzMjY1Mzg6QUFGUnJaSnJCN29CM211ekdzR0pYVXZHRTVDUXpNNUNVNG8iLCAicGF5bWVudF9wcm92aWRlcl90b2tlbiI6ICIyODQ2ODUwNjM6VEVTVDpNbU01WVdKaFl6a3hNMlUxIiwgImJvdF9uYW1lIjogIlBUQiB0ZXN0cyBvbiBUcmF2aXMgdXNpbmcgQ1B5dGhvbiAzLjUiLCAic3VwZXJfZ3JvdXBfaWQiOiAiLTEwMDE0OTY5MTc3NTAiLCAiYm90X3VzZXJuYW1lIjogIkBwdGJfdHJhdmlzX2NweXRob25fMzVfYm90In0sIHsidG9rZW4iOiAiNjQwMjA4OTQzOkFBRmhCalFwOXFtM1JUeFN6VXBZekJRakNsZS1Kano1aGNrIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6WXpoa1pUZzFOamMxWXpWbCIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gVHJhdmlzIHVzaW5nIENQeXRob24gMy42IiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxMzMzODcxNDYxIiwgImJvdF91c2VybmFtZSI6ICJAcHRiX3RyYXZpc19jcHl0aG9uXzM2X2JvdCJ9LCB7InRva2VuIjogIjY5NTEwNDA4ODpBQUhmenlsSU9qU0lJUy1lT25JMjB5MkUyMEhvZEhzZnotMCIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOk9HUTFNRGd3WmpJd1pqRmwiLCAiYm90X25hbWUiOiAiUFRCIHRlc3RzIG9uIFRyYXZpcyB1c2luZyBDUHl0aG9uIDMuNyIsICJzdXBlcl9ncm91cF9pZCI6ICItMTAwMTQ3ODI5MzcxNCIsICJib3RfdXNlcm5hbWUiOiAiQHB0Yl90cmF2aXNfY3B5dGhvbl8zN19ib3QifSwgeyJ0b2tlbiI6ICI2OTE0MjM1NTQ6QUFGOFdrakNaYm5IcVBfaTZHaFRZaXJGRWxackdhWU9oWDAiLCAicGF5bWVudF9wcm92aWRlcl90b2tlbiI6ICIyODQ2ODUwNjM6VEVTVDpZamM1TlRoaU1tUXlNV1ZoIiwgImJvdF9uYW1lIjogIlBUQiB0ZXN0cyBvbiBUcmF2aXMgdXNpbmcgUHlQeSAyLjciLCAic3VwZXJfZ3JvdXBfaWQiOiAiLTEwMDEzNjM5MzI1NzMiLCAiYm90X3VzZXJuYW1lIjogIkBwdGJfdHJhdmlzX3B5cHlfMjdfYm90In0sIHsidG9rZW4iOiAiNjg0MzM5OTg0OkFBRk1nRUVqcDAxcjVyQjAwN3lDZFZOc2c4QWxOc2FVLWNjIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6TVRBek1UWTNNR1V5TmpnMCIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gVHJhdmlzIHVzaW5nIFB5UHkgMy41IiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxNDA3ODM2NjA1IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX3RyYXZpc19weXB5XzM1X2JvdCJ9LCB7InRva2VuIjogIjY5MDA5MTM0NzpBQUZMbVI1cEFCNVljcGVfbU9oN3pNNEpGQk9oMHozVDBUbyIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOlpEaGxOekU1TURrd1lXSmkiLCAiYm90X25hbWUiOiAiUFRCIHRlc3RzIG9uIEFwcFZleW9yIHVzaW5nIENQeXRob24gMy40IiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxMjc5NjAwMDI2IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX2FwcHZleW9yX2NweXRob25fMzRfYm90In0sIHsidG9rZW4iOiAiNjk0MzA4MDUyOkFBRUIyX3NvbkNrNTVMWTlCRzlBTy1IOGp4aVBTNTVvb0JBIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6WW1aaVlXWm1NakpoWkdNeSIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gQXBwVmV5b3IgdXNpbmcgQ1B5dGhvbiAyLjciLCAic3VwZXJfZ3JvdXBfaWQiOiAiLTEwMDEyOTMwNzkxNjUiLCAiYm90X3VzZXJuYW1lIjogIkBwdGJfYXBwdmV5b3JfY3B5dGhvbl8yN19ib3QifSwgeyJ0b2tlbiI6ICIxMDU1Mzk3NDcxOkFBRzE4bkJfUzJXQXd1SjNnN29oS0JWZ1hYY2VNbklPeVNjIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6TmpBd056QXpZalZpTkdOayIsICJuYW1lIjogIlBUQiB0ZXN0cyBbMF0iLCAic3VwZXJfZ3JvdXBfaWQiOiAiLTEwMDExODU1MDk2MzYiLCAidXNlcm5hbWUiOiAicHRiXzBfYm90In0sIHsidG9rZW4iOiAiMTA0NzMyNjc3MTpBQUY4bk90ODFGcFg4bGJidno4VWV3UVF2UmZUYkZmQnZ1SSIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOllUVTFOVEk0WkdSallqbGkiLCAibmFtZSI6ICJQVEIgdGVzdHMgWzFdIiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxNDg0Nzk3NjEyIiwgInVzZXJuYW1lIjogInB0Yl8xX2JvdCJ9LCB7InRva2VuIjogIjk3MTk5Mjc0NTpBQUdPa09hVzBOSGpnSXY1LTlqUWJPajR2R3FkaFNGLVV1cyIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOk5XWmtNV1ZoWWpsallqVTUiLCAibmFtZSI6ICJQVEIgdGVzdHMgWzJdIiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxNDAyMjU1MDcwIiwgInVzZXJuYW1lIjogInB0Yl8yX2JvdCJ9XQ==
|
||||||
TEST_NO_PYTZ : "true"
|
TEST_PYTZ : "false"
|
||||||
TEST_NO_PASSPORT: "true"
|
TEST_PASSPORT: "false"
|
||||||
|
TEST_RATE_LIMITER: "false"
|
||||||
TEST_BUILD: "true"
|
TEST_BUILD: "true"
|
||||||
shell: bash --noprofile --norc {0}
|
shell: bash --noprofile --norc {0}
|
||||||
|
|
||||||
|
@ -95,6 +104,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
python -W ignore -m pip install --upgrade pip
|
python -W ignore -m pip install --upgrade pip
|
||||||
python -W ignore -m pip install -r requirements.txt
|
python -W ignore -m pip install -r requirements.txt
|
||||||
|
python -W ignore -m pip install -r requirements-opts.txt
|
||||||
python -W ignore -m pip install -r requirements-dev.txt
|
python -W ignore -m pip install -r requirements-dev.txt
|
||||||
- name: Compare to official api
|
- name: Compare to official api
|
||||||
run: |
|
run: |
|
||||||
|
|
|
@ -35,6 +35,7 @@ repos:
|
||||||
- tornado~=6.2
|
- tornado~=6.2
|
||||||
- APScheduler~=3.9.1
|
- APScheduler~=3.9.1
|
||||||
- cachetools~=5.2.0
|
- cachetools~=5.2.0
|
||||||
|
- aiolimiter~=1.0.0
|
||||||
- . # this basically does `pip install -e .`
|
- . # this basically does `pip install -e .`
|
||||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
rev: v0.971
|
rev: v0.971
|
||||||
|
@ -43,7 +44,6 @@ repos:
|
||||||
name: mypy-ptb
|
name: mypy-ptb
|
||||||
files: ^telegram/.*\.py$
|
files: ^telegram/.*\.py$
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- types-ujson
|
|
||||||
- types-pytz
|
- types-pytz
|
||||||
- types-cryptography
|
- types-cryptography
|
||||||
- types-cachetools
|
- types-cachetools
|
||||||
|
@ -51,6 +51,7 @@ repos:
|
||||||
- tornado~=6.2
|
- tornado~=6.2
|
||||||
- APScheduler~=3.9.1
|
- APScheduler~=3.9.1
|
||||||
- cachetools~=5.2.0
|
- cachetools~=5.2.0
|
||||||
|
- aiolimiter~=1.0.0
|
||||||
- . # this basically does `pip install -e .`
|
- . # this basically does `pip install -e .`
|
||||||
- id: mypy
|
- id: mypy
|
||||||
name: mypy-examples
|
name: mypy-examples
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
include LICENSE LICENSE.lesser Makefile requirements.txt README_RAW.rst telegram/py.typed
|
include LICENSE LICENSE.lesser Makefile requirements.txt requirements-opts.txt README_RAW.rst telegram/py.typed
|
||||||
|
|
|
@ -138,6 +138,7 @@ PTB can be installed with optional dependencies:
|
||||||
|
|
||||||
* ``pip install python-telegram-bot[passport]`` installs the `cryptography>=3.0 <https://cryptography.io/en/stable>`_ library. Use this, if you want to use Telegram Passport related functionality.
|
* ``pip install python-telegram-bot[passport]`` installs the `cryptography>=3.0 <https://cryptography.io/en/stable>`_ library. Use this, if you want to use Telegram Passport related functionality.
|
||||||
* ``pip install python-telegram-bot[socks]`` installs ``httpx[socks]``. Use this, if you want to work behind a Socks5 server.
|
* ``pip install python-telegram-bot[socks]`` installs ``httpx[socks]``. Use this, if you want to work behind a Socks5 server.
|
||||||
|
* ``pip install python-telegram-bot[rate-limiter]`` installs ``aiolimiter~=1.0.0``. Use this, if you want to use ``telegram.ext.AIORateLimiter``.
|
||||||
|
|
||||||
Quick Start
|
Quick Start
|
||||||
===========
|
===========
|
||||||
|
|
|
@ -490,7 +490,7 @@ def autodoc_process_bases(app, name, obj, option, bases: list):
|
||||||
# Now convert `telegram._message.Message` to `telegram.Message` etc
|
# Now convert `telegram._message.Message` to `telegram.Message` etc
|
||||||
match = re.search(pattern=r"(telegram(\.ext|))\.[_\w\.]+", string=base)
|
match = re.search(pattern=r"(telegram(\.ext|))\.[_\w\.]+", string=base)
|
||||||
if not match or "_utils" in base:
|
if not match or "_utils" in base:
|
||||||
return
|
continue
|
||||||
|
|
||||||
parts = match.group(0).split(".")
|
parts = match.group(0).split(".")
|
||||||
|
|
||||||
|
|
6
docs/source/telegram.ext.aioratelimiter.rst
Normal file
6
docs/source/telegram.ext.aioratelimiter.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
telegram.ext.AIORateLimiter
|
||||||
|
============================
|
||||||
|
|
||||||
|
.. autoclass:: telegram.ext.AIORateLimiter
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
6
docs/source/telegram.ext.baseratelimiter.rst
Normal file
6
docs/source/telegram.ext.baseratelimiter.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
telegram.ext.BaseRateLimiter
|
||||||
|
============================
|
||||||
|
|
||||||
|
.. autoclass:: telegram.ext.BaseRateLimiter
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
|
@ -3,5 +3,4 @@ telegram.ext.ExtBot
|
||||||
|
|
||||||
.. autoclass:: telegram.ext.ExtBot
|
.. autoclass:: telegram.ext.ExtBot
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
:members: insert_callback_data, defaults, rate_limiter, initialize, shutdown
|
||||||
.. autofunction:: telegram.ext.ExtBot.insert_callback_data
|
|
||||||
|
|
|
@ -55,3 +55,11 @@ Arbitrary Callback Data
|
||||||
|
|
||||||
telegram.ext.callbackdatacache
|
telegram.ext.callbackdatacache
|
||||||
telegram.ext.invalidcallbackdata
|
telegram.ext.invalidcallbackdata
|
||||||
|
|
||||||
|
Rate Limiting
|
||||||
|
-------------
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
|
||||||
|
telegram.ext.baseratelimiter
|
||||||
|
telegram.ext.aioratelimiter
|
4
requirements-all.txt
Normal file
4
requirements-all.txt
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
-r requirements.txt
|
||||||
|
-r requirements-dev.txt
|
||||||
|
-r requirements-opts.txt
|
||||||
|
-r docs/requirements-docs.txt
|
|
@ -1,6 +1,3 @@
|
||||||
# cryptography is an optional dependency, but running the tests properly requires it
|
|
||||||
cryptography!=3.4,!=3.4.1,!=3.4.2,!=3.4.3
|
|
||||||
|
|
||||||
pre-commit
|
pre-commit
|
||||||
|
|
||||||
pytest==7.1.2
|
pytest==7.1.2
|
||||||
|
|
7
requirements-opts.txt
Normal file
7
requirements-opts.txt
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
# Format:
|
||||||
|
# package_name==version # req-1, req-2, req-3!ext
|
||||||
|
# `pip install ptb-raw[req-1/2]` will install `package_name`
|
||||||
|
# `pip install ptb[req-1/2/3]` will also install `package_name`
|
||||||
|
httpx[socks] # socks
|
||||||
|
cryptography!=3.4,!=3.4.1,!=3.4.2,!=3.4.3,>=3.0 # passport
|
||||||
|
aiolimiter~=1.0.0 # rate-limiter!ext
|
26
setup.py
26
setup.py
|
@ -2,6 +2,7 @@
|
||||||
"""The setup and build script for the python-telegram-bot library."""
|
"""The setup and build script for the python-telegram-bot library."""
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from setuptools import find_packages, setup
|
from setuptools import find_packages, setup
|
||||||
|
@ -35,6 +36,25 @@ def get_packages_requirements(raw=False):
|
||||||
return packs, reqs
|
return packs, reqs
|
||||||
|
|
||||||
|
|
||||||
|
def get_optional_requirements(raw=False):
|
||||||
|
"""Build the optional dependencies"""
|
||||||
|
requirements = defaultdict(list)
|
||||||
|
|
||||||
|
with Path("requirements-opts.txt").open() as reqs:
|
||||||
|
for line in reqs:
|
||||||
|
if line.startswith("#"):
|
||||||
|
continue
|
||||||
|
dependency, names = line.split("#")
|
||||||
|
dependency = dependency.strip()
|
||||||
|
for name in names.split(","):
|
||||||
|
name = name.strip()
|
||||||
|
if name.endswith("!ext") and raw:
|
||||||
|
continue
|
||||||
|
requirements[name].append(dependency)
|
||||||
|
|
||||||
|
return requirements
|
||||||
|
|
||||||
|
|
||||||
def get_setup_kwargs(raw=False):
|
def get_setup_kwargs(raw=False):
|
||||||
"""Builds a dictionary of kwargs for the setup function"""
|
"""Builds a dictionary of kwargs for the setup function"""
|
||||||
packages, requirements = get_packages_requirements(raw=raw)
|
packages, requirements = get_packages_requirements(raw=raw)
|
||||||
|
@ -69,11 +89,7 @@ def get_setup_kwargs(raw=False):
|
||||||
long_description_content_type="text/x-rst",
|
long_description_content_type="text/x-rst",
|
||||||
packages=packages,
|
packages=packages,
|
||||||
install_requires=requirements,
|
install_requires=requirements,
|
||||||
extras_require={
|
extras_require=get_optional_requirements(raw=raw),
|
||||||
"socks": "httpx[socks]",
|
|
||||||
# 3.4-3.4.3 contained some cyclical import bugs
|
|
||||||
"passport": "cryptography!=3.4,!=3.4.1,!=3.4.2,!=3.4.3,>=3.0",
|
|
||||||
},
|
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
classifiers=[
|
classifiers=[
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
|
|
@ -296,6 +296,25 @@ class Bot(TelegramObject, AbstractAsyncContextManager):
|
||||||
# Drop any None values because Telegram doesn't handle them well
|
# Drop any None values because Telegram doesn't handle them well
|
||||||
data = {key: value for key, value in data.items() if value is not None}
|
data = {key: value for key, value in data.items() if value is not None}
|
||||||
|
|
||||||
|
return await self._do_post(
|
||||||
|
endpoint=endpoint,
|
||||||
|
data=data,
|
||||||
|
read_timeout=read_timeout,
|
||||||
|
write_timeout=write_timeout,
|
||||||
|
connect_timeout=connect_timeout,
|
||||||
|
pool_timeout=pool_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _do_post(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
data: JSONDict,
|
||||||
|
*,
|
||||||
|
read_timeout: ODVInput[float] = DEFAULT_NONE,
|
||||||
|
write_timeout: ODVInput[float] = DEFAULT_NONE,
|
||||||
|
connect_timeout: ODVInput[float] = DEFAULT_NONE,
|
||||||
|
pool_timeout: ODVInput[float] = DEFAULT_NONE,
|
||||||
|
) -> Union[bool, JSONDict, None]:
|
||||||
# This also converts datetimes into timestamps.
|
# This also converts datetimes into timestamps.
|
||||||
# We don't do this earlier so that _insert_defaults (see above) has a chance to convert
|
# We don't do this earlier so that _insert_defaults (see above) has a chance to convert
|
||||||
# to the default timezone in case this is called by ExtBot
|
# to the default timezone in case this is called by ExtBot
|
||||||
|
@ -2902,7 +2921,7 @@ class Bot(TelegramObject, AbstractAsyncContextManager):
|
||||||
api_kwargs=api_kwargs,
|
api_kwargs=api_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
return UserProfilePhotos.de_json(result, self) # type: ignore[return-value, arg-type]
|
return UserProfilePhotos.de_json(result, self) # type: ignore[arg-type,return-value]
|
||||||
|
|
||||||
@_log
|
@_log
|
||||||
async def get_file(
|
async def get_file(
|
||||||
|
|
|
@ -19,10 +19,13 @@
|
||||||
"""Extensions over the Telegram Bot API to facilitate bot making"""
|
"""Extensions over the Telegram Bot API to facilitate bot making"""
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
"AIORateLimiter",
|
||||||
"Application",
|
"Application",
|
||||||
"ApplicationBuilder",
|
"ApplicationBuilder",
|
||||||
"ApplicationHandlerStop",
|
"ApplicationHandlerStop",
|
||||||
|
"BaseHandler",
|
||||||
"BasePersistence",
|
"BasePersistence",
|
||||||
|
"BaseRateLimiter",
|
||||||
"CallbackContext",
|
"CallbackContext",
|
||||||
"CallbackDataCache",
|
"CallbackDataCache",
|
||||||
"CallbackQueryHandler",
|
"CallbackQueryHandler",
|
||||||
|
@ -36,7 +39,6 @@ __all__ = (
|
||||||
"DictPersistence",
|
"DictPersistence",
|
||||||
"ExtBot",
|
"ExtBot",
|
||||||
"filters",
|
"filters",
|
||||||
"BaseHandler",
|
|
||||||
"InlineQueryHandler",
|
"InlineQueryHandler",
|
||||||
"InvalidCallbackData",
|
"InvalidCallbackData",
|
||||||
"Job",
|
"Job",
|
||||||
|
@ -56,9 +58,11 @@ __all__ = (
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import filters
|
from . import filters
|
||||||
|
from ._aioratelimiter import AIORateLimiter
|
||||||
from ._application import Application, ApplicationHandlerStop
|
from ._application import Application, ApplicationHandlerStop
|
||||||
from ._applicationbuilder import ApplicationBuilder
|
from ._applicationbuilder import ApplicationBuilder
|
||||||
from ._basepersistence import BasePersistence, PersistenceInput
|
from ._basepersistence import BasePersistence, PersistenceInput
|
||||||
|
from ._baseratelimiter import BaseRateLimiter
|
||||||
from ._callbackcontext import CallbackContext
|
from ._callbackcontext import CallbackContext
|
||||||
from ._callbackdatacache import CallbackDataCache, InvalidCallbackData
|
from ._callbackdatacache import CallbackDataCache, InvalidCallbackData
|
||||||
from ._callbackqueryhandler import CallbackQueryHandler
|
from ._callbackqueryhandler import CallbackQueryHandler
|
||||||
|
|
262
telegram/ext/_aioratelimiter.py
Normal file
262
telegram/ext/_aioratelimiter.py
Normal file
|
@ -0,0 +1,262 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
#
|
||||||
|
# A library that provides a Python interface to the Telegram Bot API
|
||||||
|
# Copyright (C) 2015-2022
|
||||||
|
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser Public License
|
||||||
|
# along with this program. If not, see [http://www.gnu.org/licenses/].
|
||||||
|
"""This module contains an implementation of the BaseRateLimiter class based on the aiolimiter
|
||||||
|
library.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from typing import Any, AsyncIterator, Callable, Coroutine, Dict, Optional, Union
|
||||||
|
|
||||||
|
try:
|
||||||
|
from aiolimiter import AsyncLimiter
|
||||||
|
|
||||||
|
AIO_LIMITER_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
AIO_LIMITER_AVAILABLE = False
|
||||||
|
|
||||||
|
from telegram._utils.types import JSONDict
|
||||||
|
from telegram.error import RetryAfter
|
||||||
|
from telegram.ext._baseratelimiter import BaseRateLimiter
|
||||||
|
|
||||||
|
# Useful for something like:
|
||||||
|
# async with group_limiter if group else null_context():
|
||||||
|
# so we don't have to differentiate between "I'm using a context manager" and "I'm not"
|
||||||
|
if sys.version_info >= (3, 10):
|
||||||
|
null_context = contextlib.nullcontext # pylint: disable=invalid-name
|
||||||
|
else:
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def null_context() -> AsyncIterator[None]:
|
||||||
|
yield None
|
||||||
|
|
||||||
|
|
||||||
|
class AIORateLimiter(BaseRateLimiter[int]):
|
||||||
|
"""
|
||||||
|
Implementation of :class:`~telegram.ext.BaseRateLimiter` using the library
|
||||||
|
`aiolimiter <https://aiolimiter.readthedocs.io/>`_.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
If you want to use this class, you must install PTB with the optional requirement
|
||||||
|
``rate-limiter``, i.e.
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
pip install python-telegram-bot[rate-limiter]
|
||||||
|
|
||||||
|
The rate limiting is applied by combining two levels of throttling and :meth:`process_request`
|
||||||
|
roughly boils down to::
|
||||||
|
|
||||||
|
async with group_limiter(group_id):
|
||||||
|
async with overall_limiter:
|
||||||
|
await callback(*args, **kwargs)
|
||||||
|
|
||||||
|
Here, ``group_id`` is determined by checking if there is a ``chat_id`` parameter in the
|
||||||
|
:paramref:`~telegram.ext.BaseRateLimiter.process_request.data`.
|
||||||
|
The ``overall_limiter`` is applied only if a ``chat_id`` argument is present at all.
|
||||||
|
|
||||||
|
Attention:
|
||||||
|
* Some bot methods accept a ``chat_id`` parameter in form of a ``@username`` for
|
||||||
|
supergroups and channels. As we can't know which ``@username`` corresponds to which
|
||||||
|
integer ``chat_id``, these will be treated as different groups, which may lead to
|
||||||
|
exceeding the rate limit.
|
||||||
|
* As channels can't be differentiated from supergroups by the ``@username`` or integer
|
||||||
|
``chat_id``, this also applies the group related rate limits to channels.
|
||||||
|
* A :exc:`~telegram.error.RetryAfter` exception will halt *all* requests for
|
||||||
|
:attr:`~telegram.error.RetryAfter.retry_after` + 0.1 seconds. This may be stricter than
|
||||||
|
necessary in some cases, e.g. the bot may hit a rate limit in one group but might still
|
||||||
|
be allowed to send messages in another group.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
This class is to be understood as minimal effort reference implementation.
|
||||||
|
If you would like to handle rate limiting in a more sophisticated, fine-tuned way, we
|
||||||
|
welcome you to implement your own subclass of :class:`~telegram.ext.BaseRateLimiter`.
|
||||||
|
Feel free to check out the source code of this class for inspiration.
|
||||||
|
|
||||||
|
.. versionadded:: 20.0
|
||||||
|
|
||||||
|
Args:
|
||||||
|
overall_max_rate (:obj:`float`): The maximum number of requests allowed for the entire bot
|
||||||
|
per :paramref:`overall_time_period`. When set to 0, no rate limiting will be applied.
|
||||||
|
Defaults to ``30``.
|
||||||
|
overall_time_period (:obj:`float`): The time period (in seconds) during which the
|
||||||
|
:paramref:`overall_max_rate` is enforced. When set to 0, no rate limiting will be
|
||||||
|
applied. Defaults to 1.
|
||||||
|
group_max_rate (:obj:`float`): The maximum number of requests allowed for requests related
|
||||||
|
to groups and channels per :paramref:`group_time_period`. When set to 0, no rate
|
||||||
|
limiting will be applied. Defaults to 20.
|
||||||
|
group_time_period (:obj:`float`): The time period (in seconds) during which the
|
||||||
|
:paramref:`group_time_period` is enforced. When set to 0, no rate limiting will be
|
||||||
|
applied. Defaults to 60.
|
||||||
|
max_retries (:obj:`int`): The maximum number of retries to be made in case of a
|
||||||
|
:exc:`~telegram.error.RetryAfter` exception.
|
||||||
|
If set to 0, no retries will be made. Defaults to ``0``.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_base_limiter",
|
||||||
|
"_group_limiters",
|
||||||
|
"_group_max_rate",
|
||||||
|
"_group_time_period",
|
||||||
|
"_logger",
|
||||||
|
"_max_retries",
|
||||||
|
"_retry_after_event",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
overall_max_rate: float = 30,
|
||||||
|
overall_time_period: float = 1,
|
||||||
|
group_max_rate: float = 20,
|
||||||
|
group_time_period: float = 60,
|
||||||
|
max_retries: int = 0,
|
||||||
|
) -> None:
|
||||||
|
if not AIO_LIMITER_AVAILABLE:
|
||||||
|
raise RuntimeError(
|
||||||
|
"To use `AIORateLimiter`, PTB must be installed via `pip install "
|
||||||
|
"python-telegram-bot[rate-limiter]`."
|
||||||
|
)
|
||||||
|
if overall_max_rate and overall_time_period:
|
||||||
|
self._base_limiter: Optional[AsyncLimiter] = AsyncLimiter(
|
||||||
|
max_rate=overall_max_rate, time_period=overall_time_period
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._base_limiter = None
|
||||||
|
|
||||||
|
if group_max_rate and group_time_period:
|
||||||
|
self._group_max_rate = group_max_rate
|
||||||
|
self._group_time_period = group_time_period
|
||||||
|
else:
|
||||||
|
self._group_max_rate = 0
|
||||||
|
self._group_time_period = 0
|
||||||
|
|
||||||
|
self._group_limiters: Dict[Union[str, int], AsyncLimiter] = {}
|
||||||
|
self._max_retries = max_retries
|
||||||
|
self._logger = logging.getLogger(__name__)
|
||||||
|
self._retry_after_event = asyncio.Event()
|
||||||
|
self._retry_after_event.set()
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
"""Does nothing."""
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
"""Does nothing."""
|
||||||
|
|
||||||
|
def _get_group_limiter(self, group_id: Union[str, int, bool]) -> "AsyncLimiter":
|
||||||
|
# Remove limiters that haven't been used for so long that all their capacity is unused
|
||||||
|
# We only do that if we have a lot of limiters lying around to avoid looping on every call
|
||||||
|
# This is a minimal effort approach - a full-fledged cache could use a TTL approach
|
||||||
|
# or at least adapt the threshold dynamically depending on the number of active limiters
|
||||||
|
if len(self._group_limiters) > 512:
|
||||||
|
# We copy to avoid modifying the dict while we iterate over it
|
||||||
|
for key, limiter in self._group_limiters.copy().items():
|
||||||
|
if key == group_id:
|
||||||
|
continue
|
||||||
|
if limiter.has_capacity(limiter.max_rate):
|
||||||
|
del self._group_limiters[key]
|
||||||
|
|
||||||
|
if group_id not in self._group_limiters:
|
||||||
|
self._group_limiters[group_id] = AsyncLimiter(
|
||||||
|
max_rate=self._group_max_rate,
|
||||||
|
time_period=self._group_time_period,
|
||||||
|
)
|
||||||
|
return self._group_limiters[group_id]
|
||||||
|
|
||||||
|
async def _run_request(
|
||||||
|
self,
|
||||||
|
chat: bool,
|
||||||
|
group: Union[str, int, bool],
|
||||||
|
callback: Callable[..., Coroutine[Any, Any, Union[bool, JSONDict, None]]],
|
||||||
|
args: Any,
|
||||||
|
kwargs: Dict[str, Any],
|
||||||
|
) -> Union[bool, JSONDict, None]:
|
||||||
|
base_context = self._base_limiter if (chat and self._base_limiter) else null_context()
|
||||||
|
group_context = (
|
||||||
|
self._get_group_limiter(group) if group and self._group_max_rate else null_context()
|
||||||
|
)
|
||||||
|
|
||||||
|
async with group_context: # skipcq: PTC-W0062
|
||||||
|
async with base_context:
|
||||||
|
# In case a retry_after was hit, we wait with processing the request
|
||||||
|
await self._retry_after_event.wait()
|
||||||
|
|
||||||
|
return await callback(*args, **kwargs)
|
||||||
|
|
||||||
|
# mypy doesn't understand that the last run of the for loop raises an exception
|
||||||
|
async def process_request( # type: ignore[return]
|
||||||
|
self,
|
||||||
|
callback: Callable[..., Coroutine[Any, Any, Union[bool, JSONDict, None]]],
|
||||||
|
args: Any,
|
||||||
|
kwargs: Dict[str, Any],
|
||||||
|
endpoint: str, # skipcq: PYL-W0613
|
||||||
|
data: Dict[str, Any],
|
||||||
|
rate_limit_args: Optional[int],
|
||||||
|
) -> Union[bool, JSONDict, None]:
|
||||||
|
"""
|
||||||
|
Processes a request by applying rate limiting.
|
||||||
|
|
||||||
|
See :meth:`telegram.ext.BaseRateLimiter.process_request` for detailed information on the
|
||||||
|
arguments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rate_limit_args (:obj:`None` | :obj:`int`): If set, specifies the maximum number of
|
||||||
|
retries to be made in case of a :exc:`~telegram.error.RetryAfter` exception.
|
||||||
|
Defaults to :paramref:`AIORateLimiter.max_retries`.
|
||||||
|
"""
|
||||||
|
max_retries = rate_limit_args or self._max_retries
|
||||||
|
|
||||||
|
group: Union[int, str, bool] = False
|
||||||
|
chat: bool = False
|
||||||
|
chat_id = data.get("chat_id")
|
||||||
|
if chat_id is not None:
|
||||||
|
chat = True
|
||||||
|
|
||||||
|
# In case user passes integer chat id as string
|
||||||
|
try:
|
||||||
|
chat_id = int(chat_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if (isinstance(chat_id, int) and chat_id < 0) or isinstance(chat_id, str):
|
||||||
|
# string chat_id only works for channels and supergroups
|
||||||
|
# We can't really tell channels from groups though ...
|
||||||
|
group = chat_id
|
||||||
|
|
||||||
|
for i in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
return await self._run_request(
|
||||||
|
chat=chat, group=group, callback=callback, args=args, kwargs=kwargs
|
||||||
|
)
|
||||||
|
except RetryAfter as exc:
|
||||||
|
if i == max_retries:
|
||||||
|
self._logger.exception(
|
||||||
|
"Rate limit hit after maximum of %d retries", max_retries, exc_info=exc
|
||||||
|
)
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
sleep = exc.retry_after + 0.1
|
||||||
|
self._logger.info("Rate limit hit. Retrying after %f seconds", sleep)
|
||||||
|
# Make sure we don't allow other requests to be processed
|
||||||
|
self._retry_after_event.clear()
|
||||||
|
await asyncio.sleep(sleep)
|
||||||
|
finally:
|
||||||
|
# Allow other requests to be processed
|
||||||
|
self._retry_after_event.set()
|
|
@ -45,7 +45,8 @@ from telegram.request import BaseRequest
|
||||||
from telegram.request._httpxrequest import HTTPXRequest
|
from telegram.request._httpxrequest import HTTPXRequest
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from telegram.ext import BasePersistence, CallbackContext, Defaults
|
from telegram.ext import BasePersistence, BaseRateLimiter, CallbackContext, Defaults
|
||||||
|
from telegram.ext._utils.types import RLARGS
|
||||||
|
|
||||||
# Type hinting is a bit complicated here because we try to get to a sane level of
|
# Type hinting is a bit complicated here because we try to get to a sane level of
|
||||||
# leveraging generics and therefore need a number of type variables.
|
# leveraging generics and therefore need a number of type variables.
|
||||||
|
@ -81,6 +82,7 @@ _BOT_CHECKS = [
|
||||||
("defaults", "defaults"),
|
("defaults", "defaults"),
|
||||||
("arbitrary_callback_data", "arbitrary_callback_data"),
|
("arbitrary_callback_data", "arbitrary_callback_data"),
|
||||||
("private_key", "private_key"),
|
("private_key", "private_key"),
|
||||||
|
("rate_limiter", "rate_limiter instance"),
|
||||||
]
|
]
|
||||||
|
|
||||||
_TWO_ARGS_REQ = "The parameter `{}` may only be set, if no {} was set."
|
_TWO_ARGS_REQ = "The parameter `{}` may only be set, if no {} was set."
|
||||||
|
@ -139,6 +141,7 @@ class ApplicationBuilder(Generic[BT, CCT, UD, CD, BD, JQ]):
|
||||||
"_private_key",
|
"_private_key",
|
||||||
"_private_key_password",
|
"_private_key_password",
|
||||||
"_proxy_url",
|
"_proxy_url",
|
||||||
|
"_rate_limiter",
|
||||||
"_read_timeout",
|
"_read_timeout",
|
||||||
"_request",
|
"_request",
|
||||||
"_token",
|
"_token",
|
||||||
|
@ -180,6 +183,7 @@ class ApplicationBuilder(Generic[BT, CCT, UD, CD, BD, JQ]):
|
||||||
self._updater: ODVInput[Updater] = DEFAULT_NONE
|
self._updater: ODVInput[Updater] = DEFAULT_NONE
|
||||||
self._post_init: Optional[Callable[[Application], Coroutine[Any, Any, None]]] = None
|
self._post_init: Optional[Callable[[Application], Coroutine[Any, Any, None]]] = None
|
||||||
self._post_shutdown: Optional[Callable[[Application], Coroutine[Any, Any, None]]] = None
|
self._post_shutdown: Optional[Callable[[Application], Coroutine[Any, Any, None]]] = None
|
||||||
|
self._rate_limiter: ODVInput["BaseRateLimiter"] = DEFAULT_NONE
|
||||||
|
|
||||||
def _build_request(self, get_updates: bool) -> BaseRequest:
|
def _build_request(self, get_updates: bool) -> BaseRequest:
|
||||||
prefix = "_get_updates_" if get_updates else "_"
|
prefix = "_get_updates_" if get_updates else "_"
|
||||||
|
@ -227,6 +231,7 @@ class ApplicationBuilder(Generic[BT, CCT, UD, CD, BD, JQ]):
|
||||||
arbitrary_callback_data=DefaultValue.get_value(self._arbitrary_callback_data),
|
arbitrary_callback_data=DefaultValue.get_value(self._arbitrary_callback_data),
|
||||||
request=self._build_request(get_updates=False),
|
request=self._build_request(get_updates=False),
|
||||||
get_updates_request=self._build_request(get_updates=True),
|
get_updates_request=self._build_request(get_updates=True),
|
||||||
|
rate_limiter=DefaultValue.get_value(self._rate_limiter),
|
||||||
)
|
)
|
||||||
|
|
||||||
def build(
|
def build(
|
||||||
|
@ -973,10 +978,31 @@ class ApplicationBuilder(Generic[BT, CCT, UD, CD, BD, JQ]):
|
||||||
self._post_shutdown = post_shutdown
|
self._post_shutdown = post_shutdown
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
def rate_limiter(
|
||||||
|
self: "ApplicationBuilder[BT, CCT, UD, CD, BD, JQ]",
|
||||||
|
rate_limiter: "BaseRateLimiter[RLARGS]",
|
||||||
|
) -> "ApplicationBuilder[ExtBot[RLARGS], CCT, UD, CD, BD, JQ]":
|
||||||
|
"""Sets a :class:`telegram.ext.BaseRateLimiter` instance for the
|
||||||
|
:paramref:`telegram.ext.ExtBot.rate_limiter` parameter of
|
||||||
|
:attr:`telegram.ext.Application.bot`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rate_limiter (:class:`telegram.ext.BaseRateLimiter`): The rate limiter.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`ApplicationBuilder`: The same builder with the updated argument.
|
||||||
|
"""
|
||||||
|
if self._bot is not DEFAULT_NONE:
|
||||||
|
raise RuntimeError(_TWO_ARGS_REQ.format("rate_limiter", "bot instance"))
|
||||||
|
if self._updater not in (DEFAULT_NONE, None):
|
||||||
|
raise RuntimeError(_TWO_ARGS_REQ.format("rate_limiter", "updater"))
|
||||||
|
self._rate_limiter = rate_limiter
|
||||||
|
return self # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
InitApplicationBuilder = ( # This is defined all the way down here so that its type is inferred
|
InitApplicationBuilder = ( # This is defined all the way down here so that its type is inferred
|
||||||
ApplicationBuilder[ # by Pylance correctly.
|
ApplicationBuilder[ # by Pylance correctly.
|
||||||
ExtBot,
|
ExtBot[None],
|
||||||
ContextTypes.DEFAULT_TYPE,
|
ContextTypes.DEFAULT_TYPE,
|
||||||
Dict,
|
Dict,
|
||||||
Dict,
|
Dict,
|
||||||
|
|
138
telegram/ext/_baseratelimiter.py
Normal file
138
telegram/ext/_baseratelimiter.py
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
#
|
||||||
|
# A library that provides a Python interface to the Telegram Bot API
|
||||||
|
# Copyright (C) 2015-2022
|
||||||
|
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser Public License
|
||||||
|
# along with this program. If not, see [http://www.gnu.org/licenses/].
|
||||||
|
"""This module contains a class that allows to rate limit requests to the Bot API."""
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Callable, Coroutine, Dict, Generic, Optional, Union
|
||||||
|
|
||||||
|
from telegram._utils.types import JSONDict
|
||||||
|
from telegram.ext._utils.types import RLARGS
|
||||||
|
|
||||||
|
|
||||||
|
class BaseRateLimiter(ABC, Generic[RLARGS]):
|
||||||
|
"""
|
||||||
|
Abstract interface class that allows to rate limit the requests that python-telegram-bot
|
||||||
|
sends to the Telegram Bot API. An implementation of this class
|
||||||
|
must implement all abstract methods and properties.
|
||||||
|
|
||||||
|
This class is a :class:`~typing.Generic` class and accepts one type variable that specifies
|
||||||
|
the type of the argument :paramref:`~process_request.rate_limit_args` of
|
||||||
|
:meth:`process_request` and the methods of :class:`~telegram.ext.ExtBot`.
|
||||||
|
|
||||||
|
Hint:
|
||||||
|
Requests to :meth:`~telegram.Bot.get_updates` are never rate limited.
|
||||||
|
|
||||||
|
.. versionadded:: 20.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
"""Initialize resources used by this class. Must be implemented by a subclass."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
"""Stop & clear resources used by this class. Must be implemented by a subclass."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def process_request(
|
||||||
|
self,
|
||||||
|
callback: Callable[..., Coroutine[Any, Any, Union[bool, JSONDict, None]]],
|
||||||
|
args: Any,
|
||||||
|
kwargs: Dict[str, Any],
|
||||||
|
endpoint: str,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
rate_limit_args: Optional[RLARGS],
|
||||||
|
) -> Union[bool, JSONDict, None]:
|
||||||
|
"""
|
||||||
|
Process a request. Must be implemented by a subclass.
|
||||||
|
|
||||||
|
This method must call :paramref:`callback` and return the result of the call.
|
||||||
|
`When` the callback is called is up to the implementation.
|
||||||
|
|
||||||
|
Important:
|
||||||
|
This method must only return once the result of :paramref:`callback` is known!
|
||||||
|
|
||||||
|
If a :exc:`~telegram.error.RetryAfter` error is raised, this method may try to make
|
||||||
|
a new request by calling the callback again.
|
||||||
|
|
||||||
|
Warning:
|
||||||
|
This method *should not* handle any other exception raised by :paramref:`callback`!
|
||||||
|
|
||||||
|
There are basically two different approaches how a rate limiter can be implemented:
|
||||||
|
|
||||||
|
1. React only if necessary. In this case, the :paramref:`callback` is called without any
|
||||||
|
precautions. If a :exc:`~telegram.error.RetryAfter` error is raised, processing requests
|
||||||
|
is halted for the :attr:`~telegram.error.RetryAfter.retry_after` and finally the
|
||||||
|
:paramref:`callback` is called again. This approach is often amendable for bots that
|
||||||
|
don't have a large user base and/or don't send more messages than they get updates.
|
||||||
|
2. Throttle all outgoing requests. In this case the implementation makes sure that the
|
||||||
|
requests are spread out over a longer time interval in order to stay below the rate
|
||||||
|
limits. This approach is often amendable for bots that have a large user base and/or
|
||||||
|
send more messages than they get updates.
|
||||||
|
|
||||||
|
An implementation can use the information provided by :paramref:`data`,
|
||||||
|
:paramref:`endpoint` and :paramref:`rate_limit_args` to handle each request differently.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
* It is usually desirable to call :meth:`telegram.Bot.answer_inline_query`
|
||||||
|
as quickly as possible, while delaying :meth:`telegram.Bot.send_message`
|
||||||
|
is acceptable.
|
||||||
|
* There are `different <https://core.telegram.org/bots/faq\
|
||||||
|
#my-bot-is-hitting-limits-how-do-i-avoid-this>`_ rate limits for group chats and
|
||||||
|
private chats.
|
||||||
|
* When sending broadcast messages to a large number of users, these requests can
|
||||||
|
typically be delayed for a longer time than messages that are direct replies to a
|
||||||
|
user input.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback (Callable[..., :term:`coroutine`]): The coroutine function that must be called
|
||||||
|
to make the request.
|
||||||
|
args (Tuple[:obj:`object`]): The positional arguments for the :paramref:`callback`
|
||||||
|
function.
|
||||||
|
kwargs (Dict[:obj:`str`, :obj:`object`]): The keyword arguments for the
|
||||||
|
:paramref:`callback` function.
|
||||||
|
endpoint (:obj:`str`): The endpoint that the request is made for, e.g.
|
||||||
|
``"sendMessage"``.
|
||||||
|
data (Dict[:obj:`str`, :obj:`object`]): The parameters that were passed to the method
|
||||||
|
of :class:`~telegram.ext.ExtBot`. Any ``api_kwargs`` are included in this and
|
||||||
|
any :paramref:`~telegram.ext.ExtBot.defaults` are already applied.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
When calling::
|
||||||
|
|
||||||
|
await ext_bot.send_message(
|
||||||
|
chat_id=1,
|
||||||
|
text="Hello world!",
|
||||||
|
api_kwargs={"custom": "arg"}
|
||||||
|
)
|
||||||
|
|
||||||
|
then :paramref:`data` will be::
|
||||||
|
|
||||||
|
{"chat_id": 1, "text": "Hello world!", "custom": "arg"}
|
||||||
|
|
||||||
|
rate_limit_args (:obj:`None` | :class:`object`): Custom arguments passed to the methods
|
||||||
|
of :class:`~telegram.ext.ExtBot`. Can e.g. be used to specify the priority of
|
||||||
|
the request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:obj:`bool` | Dict[:obj:`str`, :obj:`object`] | :obj:`None`: The result of the
|
||||||
|
callback function.
|
||||||
|
"""
|
File diff suppressed because it is too large
Load diff
|
@ -39,8 +39,10 @@ from typing import (
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from telegram import Bot
|
from telegram import Bot
|
||||||
from telegram.ext import CallbackContext, JobQueue
|
from telegram.ext import BaseRateLimiter, CallbackContext, JobQueue
|
||||||
|
|
||||||
CCT = TypeVar("CCT", bound="CallbackContext")
|
CCT = TypeVar("CCT", bound="CallbackContext")
|
||||||
"""An instance of :class:`telegram.ext.CallbackContext` or a custom subclass.
|
"""An instance of :class:`telegram.ext.CallbackContext` or a custom subclass.
|
||||||
|
@ -101,3 +103,13 @@ JQ = TypeVar("JQ", bound=Union[None, "JobQueue"])
|
||||||
"""Type of the job queue.
|
"""Type of the job queue.
|
||||||
|
|
||||||
.. versionadded:: 20.0"""
|
.. versionadded:: 20.0"""
|
||||||
|
|
||||||
|
RL = TypeVar("RL", bound="Optional[BaseRateLimiter]")
|
||||||
|
"""Type of the rate limiter.
|
||||||
|
|
||||||
|
.. versionadded:: 20.0"""
|
||||||
|
|
||||||
|
RLARGS = TypeVar("RLARGS")
|
||||||
|
"""Type of the rate limiter arguments.
|
||||||
|
|
||||||
|
.. versionadded:: 20.0"""
|
||||||
|
|
|
@ -558,6 +558,7 @@ async def check_shortcut_call(
|
||||||
bot: The bot
|
bot: The bot
|
||||||
bot_method_name: The bot methods name, e.g. `'send_message'`
|
bot_method_name: The bot methods name, e.g. `'send_message'`
|
||||||
skip_params: Parameters that are allowed to be missing, e.g. `['inline_message_id']`
|
skip_params: Parameters that are allowed to be missing, e.g. `['inline_message_id']`
|
||||||
|
`rate_limit_args` will be skipped by default
|
||||||
shortcut_kwargs: The kwargs passed by the shortcut directly, e.g. ``chat_id``
|
shortcut_kwargs: The kwargs passed by the shortcut directly, e.g. ``chat_id``
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
@ -565,8 +566,13 @@ async def check_shortcut_call(
|
||||||
"""
|
"""
|
||||||
if not skip_params:
|
if not skip_params:
|
||||||
skip_params = set()
|
skip_params = set()
|
||||||
|
else:
|
||||||
|
skip_params = set(skip_params)
|
||||||
|
skip_params.add("rate_limit_args")
|
||||||
if not shortcut_kwargs:
|
if not shortcut_kwargs:
|
||||||
shortcut_kwargs = set()
|
shortcut_kwargs = set()
|
||||||
|
else:
|
||||||
|
shortcut_kwargs = set(shortcut_kwargs)
|
||||||
|
|
||||||
orig_bot_method = getattr(bot, bot_method_name)
|
orig_bot_method = getattr(bot, bot_method_name)
|
||||||
bot_signature = inspect.signature(orig_bot_method)
|
bot_signature = inspect.signature(orig_bot_method)
|
||||||
|
|
|
@ -23,6 +23,7 @@ import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from telegram.ext import (
|
from telegram.ext import (
|
||||||
|
AIORateLimiter,
|
||||||
Application,
|
Application,
|
||||||
ApplicationBuilder,
|
ApplicationBuilder,
|
||||||
ContextTypes,
|
ContextTypes,
|
||||||
|
@ -82,6 +83,7 @@ class TestApplicationBuilder:
|
||||||
assert app.bot.private_key is None
|
assert app.bot.private_key is None
|
||||||
assert app.bot.arbitrary_callback_data is False
|
assert app.bot.arbitrary_callback_data is False
|
||||||
assert app.bot.defaults is None
|
assert app.bot.defaults is None
|
||||||
|
assert app.bot.rate_limiter is None
|
||||||
|
|
||||||
get_updates_client = app.bot._request[0]._client
|
get_updates_client = app.bot._request[0]._client
|
||||||
assert get_updates_client.limits == httpx.Limits(
|
assert get_updates_client.limits == httpx.Limits(
|
||||||
|
@ -196,6 +198,7 @@ class TestApplicationBuilder:
|
||||||
"proxy_url",
|
"proxy_url",
|
||||||
"bot",
|
"bot",
|
||||||
"update_queue",
|
"update_queue",
|
||||||
|
"rate_limiter",
|
||||||
]
|
]
|
||||||
+ [entry[0] for entry in _BOT_CHECKS],
|
+ [entry[0] for entry in _BOT_CHECKS],
|
||||||
)
|
)
|
||||||
|
@ -247,10 +250,13 @@ class TestApplicationBuilder:
|
||||||
defaults = Defaults()
|
defaults = Defaults()
|
||||||
request = HTTPXRequest()
|
request = HTTPXRequest()
|
||||||
get_updates_request = HTTPXRequest()
|
get_updates_request = HTTPXRequest()
|
||||||
|
rate_limiter = AIORateLimiter()
|
||||||
builder.token(bot.token).base_url("base_url").base_file_url("base_file_url").private_key(
|
builder.token(bot.token).base_url("base_url").base_file_url("base_file_url").private_key(
|
||||||
PRIVATE_KEY
|
PRIVATE_KEY
|
||||||
).defaults(defaults).arbitrary_callback_data(42).request(request).get_updates_request(
|
).defaults(defaults).arbitrary_callback_data(42).request(request).get_updates_request(
|
||||||
get_updates_request
|
get_updates_request
|
||||||
|
).rate_limiter(
|
||||||
|
rate_limiter
|
||||||
)
|
)
|
||||||
built_bot = builder.build().bot
|
built_bot = builder.build().bot
|
||||||
|
|
||||||
|
@ -266,6 +272,7 @@ class TestApplicationBuilder:
|
||||||
assert built_bot._request[0] is get_updates_request
|
assert built_bot._request[0] is get_updates_request
|
||||||
assert built_bot.callback_data_cache.maxsize == 42
|
assert built_bot.callback_data_cache.maxsize == 42
|
||||||
assert built_bot.private_key
|
assert built_bot.private_key
|
||||||
|
assert built_bot.rate_limiter is rate_limiter
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Client:
|
class Client:
|
||||||
|
|
|
@ -481,9 +481,9 @@ class TestBot:
|
||||||
corresponding methods of tg.Bot.
|
corresponding methods of tg.Bot.
|
||||||
"""
|
"""
|
||||||
# Some methods of ext.ExtBot
|
# Some methods of ext.ExtBot
|
||||||
global_extra_args = set()
|
global_extra_args = {"rate_limit_args"}
|
||||||
extra_args_per_method = defaultdict(
|
extra_args_per_method = defaultdict(
|
||||||
set, {"__init__": {"arbitrary_callback_data", "defaults"}}
|
set, {"__init__": {"arbitrary_callback_data", "defaults", "rate_limiter"}}
|
||||||
)
|
)
|
||||||
different_hints_per_method = defaultdict(set, {"__setattr__": {"ext_bot"}})
|
different_hints_per_method = defaultdict(set, {"__setattr__": {"ext_bot"}})
|
||||||
|
|
||||||
|
@ -2948,3 +2948,8 @@ class TestBot:
|
||||||
assert (
|
assert (
|
||||||
"api_kwargs" in param_names
|
"api_kwargs" in param_names
|
||||||
), f"{bot_method_name} is missing the parameter `api_kwargs`"
|
), f"{bot_method_name} is missing the parameter `api_kwargs`"
|
||||||
|
|
||||||
|
if bot_class is ExtBot and bot_method_name.replace("_", "").lower() != "getupdates":
|
||||||
|
assert (
|
||||||
|
"rate_limit_args" in param_names
|
||||||
|
), f"{bot_method_name} of ExtBot is missing the parameter `rate_limit_args`"
|
||||||
|
|
|
@ -48,16 +48,16 @@ Because imports in pytest are intricate, we just run
|
||||||
|
|
||||||
pytest -k test_helpers.py
|
pytest -k test_helpers.py
|
||||||
|
|
||||||
with the TEST_NO_PYTZ environment variable set in addition to the regular test suite.
|
with the TEST_PYTZ environment variable set to False in addition to the regular test suite.
|
||||||
Because actually uninstalling pytz would lead to errors in the test suite we just mock the
|
Because actually uninstalling pytz would lead to errors in the test suite we just mock the
|
||||||
import to raise the expected exception.
|
import to raise the expected exception.
|
||||||
|
|
||||||
Note that a fixture that just does this for every test that needs it is a nice idea, but for some
|
Note that a fixture that just does this for every test that needs it is a nice idea, but for some
|
||||||
reason makes test_updater.py hang indefinitely on GitHub Actions (at least when Hinrich tried that)
|
reason makes test_updater.py hang indefinitely on GitHub Actions (at least when Hinrich tried that)
|
||||||
"""
|
"""
|
||||||
TEST_NO_PYTZ = env_var_2_bool(os.getenv("TEST_NO_PYTZ", False))
|
TEST_PYTZ = env_var_2_bool(os.getenv("TEST_PYTZ", True))
|
||||||
|
|
||||||
if TEST_NO_PYTZ:
|
if not TEST_PYTZ:
|
||||||
orig_import = __import__
|
orig_import = __import__
|
||||||
|
|
||||||
def import_mock(module_name, *args, **kwargs):
|
def import_mock(module_name, *args, **kwargs):
|
||||||
|
@ -72,7 +72,7 @@ if TEST_NO_PYTZ:
|
||||||
class TestDatetime:
|
class TestDatetime:
|
||||||
def test_helpers_utc(self):
|
def test_helpers_utc(self):
|
||||||
# Here we just test, that we got the correct UTC variant
|
# Here we just test, that we got the correct UTC variant
|
||||||
if TEST_NO_PYTZ:
|
if not TEST_PYTZ:
|
||||||
assert tg_dtm.UTC is tg_dtm.DTM_UTC
|
assert tg_dtm.UTC is tg_dtm.DTM_UTC
|
||||||
else:
|
else:
|
||||||
assert tg_dtm.UTC is not tg_dtm.DTM_UTC
|
assert tg_dtm.UTC is not tg_dtm.DTM_UTC
|
||||||
|
|
|
@ -24,7 +24,7 @@ Currently this only means that cryptography is not installed.
|
||||||
Because imports in pytest are intricate, we just run
|
Because imports in pytest are intricate, we just run
|
||||||
pytest -k test_no_passport.py
|
pytest -k test_no_passport.py
|
||||||
|
|
||||||
with the TEST_NO_PASSPORT environment variable set in addition to the regular test suite.
|
with the TEST_PASSPORT environment variable set to False in addition to the regular test suite.
|
||||||
Because actually uninstalling the optional dependencies would lead to errors in the test suite we
|
Because actually uninstalling the optional dependencies would lead to errors in the test suite we
|
||||||
just mock the import to raise the expected exception.
|
just mock the import to raise the expected exception.
|
||||||
|
|
||||||
|
@ -41,9 +41,9 @@ from telegram import _bot as bot
|
||||||
from telegram._passport import credentials as credentials
|
from telegram._passport import credentials as credentials
|
||||||
from tests.conftest import env_var_2_bool
|
from tests.conftest import env_var_2_bool
|
||||||
|
|
||||||
TEST_NO_PASSPORT = env_var_2_bool(os.getenv("TEST_NO_PASSPORT", False))
|
TEST_PASSPORT = env_var_2_bool(os.getenv("TEST_PASSPORT", True))
|
||||||
|
|
||||||
if TEST_NO_PASSPORT:
|
if not TEST_PASSPORT:
|
||||||
orig_import = __import__
|
orig_import = __import__
|
||||||
|
|
||||||
def import_mock(module_name, *args, **kwargs):
|
def import_mock(module_name, *args, **kwargs):
|
||||||
|
@ -58,24 +58,24 @@ if TEST_NO_PASSPORT:
|
||||||
|
|
||||||
class TestNoPassport:
|
class TestNoPassport:
|
||||||
"""
|
"""
|
||||||
The monkeypatches simulate cryptography not being installed even when TEST_NO_PASSPORT is
|
The monkeypatches simulate cryptography not being installed even when TEST_PASSPORT is
|
||||||
False, though that doesn't test the actual imports
|
True, though that doesn't test the actual imports
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def test_bot_init(self, bot_info, monkeypatch):
|
def test_bot_init(self, bot_info, monkeypatch):
|
||||||
if not TEST_NO_PASSPORT:
|
if TEST_PASSPORT:
|
||||||
monkeypatch.setattr(bot, "CRYPTO_INSTALLED", False)
|
monkeypatch.setattr(bot, "CRYPTO_INSTALLED", False)
|
||||||
with pytest.raises(RuntimeError, match="passport"):
|
with pytest.raises(RuntimeError, match="passport"):
|
||||||
bot.Bot(bot_info["token"], private_key=1, private_key_password=2)
|
bot.Bot(bot_info["token"], private_key=1, private_key_password=2)
|
||||||
|
|
||||||
def test_credentials_decrypt(self, monkeypatch):
|
def test_credentials_decrypt(self, monkeypatch):
|
||||||
if not TEST_NO_PASSPORT:
|
if TEST_PASSPORT:
|
||||||
monkeypatch.setattr(credentials, "CRYPTO_INSTALLED", False)
|
monkeypatch.setattr(credentials, "CRYPTO_INSTALLED", False)
|
||||||
with pytest.raises(RuntimeError, match="passport"):
|
with pytest.raises(RuntimeError, match="passport"):
|
||||||
credentials.decrypt(1, 1, 1)
|
credentials.decrypt(1, 1, 1)
|
||||||
|
|
||||||
def test_encrypted_credentials_decrypted_secret(self, monkeypatch):
|
def test_encrypted_credentials_decrypted_secret(self, monkeypatch):
|
||||||
if not TEST_NO_PASSPORT:
|
if TEST_PASSPORT:
|
||||||
monkeypatch.setattr(credentials, "CRYPTO_INSTALLED", False)
|
monkeypatch.setattr(credentials, "CRYPTO_INSTALLED", False)
|
||||||
ec = credentials.EncryptedCredentials("data", "hash", "secret")
|
ec = credentials.EncryptedCredentials("data", "hash", "secret")
|
||||||
with pytest.raises(RuntimeError, match="passport"):
|
with pytest.raises(RuntimeError, match="passport"):
|
||||||
|
|
364
tests/test_ratelimiter.py
Normal file
364
tests/test_ratelimiter.py
Normal file
|
@ -0,0 +1,364 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
#
|
||||||
|
# A library that provides a Python interface to the Telegram Bot API
|
||||||
|
# Copyright (C) 2015-2022
|
||||||
|
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser Public License
|
||||||
|
# along with this program. If not, see [http://www.gnu.org/licenses/].
|
||||||
|
|
||||||
|
"""
|
||||||
|
We mostly test on directly on AIORateLimiter here, b/c BaseRateLimiter doesn't contain anything
|
||||||
|
notable
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flaky import flaky
|
||||||
|
|
||||||
|
from telegram import BotCommand, Chat, Message, User
|
||||||
|
from telegram.constants import ParseMode
|
||||||
|
from telegram.error import RetryAfter
|
||||||
|
from telegram.ext import AIORateLimiter, BaseRateLimiter, Defaults, ExtBot
|
||||||
|
from telegram.request import BaseRequest, RequestData
|
||||||
|
from tests.conftest import env_var_2_bool
|
||||||
|
|
||||||
|
TEST_RATE_LIMITER = env_var_2_bool(os.getenv("TEST_RATE_LIMITER", True))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
TEST_RATE_LIMITER, reason="Only relevant if the optional dependency is not installed"
|
||||||
|
)
|
||||||
|
class TestNoRateLimiter:
|
||||||
|
def test_init(self):
|
||||||
|
with pytest.raises(RuntimeError, match=r"python-telegram-bot\[rate-limiter\]"):
|
||||||
|
AIORateLimiter()
|
||||||
|
|
||||||
|
|
||||||
|
class TestBaseRateLimiter:
|
||||||
|
rl_received = None
|
||||||
|
request_received = None
|
||||||
|
|
||||||
|
async def test_no_rate_limiter(self, bot):
|
||||||
|
with pytest.raises(ValueError, match="if a `ExtBot.rate_limiter` is set"):
|
||||||
|
await bot.send_message(chat_id=42, text="test", rate_limit_args="something")
|
||||||
|
|
||||||
|
async def test_argument_passing(self, bot_info, monkeypatch, bot):
|
||||||
|
class TestRateLimiter(BaseRateLimiter):
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def process_request(
|
||||||
|
self,
|
||||||
|
callback,
|
||||||
|
args,
|
||||||
|
kwargs,
|
||||||
|
endpoint,
|
||||||
|
data,
|
||||||
|
rate_limit_args,
|
||||||
|
):
|
||||||
|
if TestBaseRateLimiter.rl_received is None:
|
||||||
|
TestBaseRateLimiter.rl_received = []
|
||||||
|
TestBaseRateLimiter.rl_received.append((endpoint, data, rate_limit_args))
|
||||||
|
return await callback(*args, **kwargs)
|
||||||
|
|
||||||
|
class TestRequest(BaseRequest):
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def do_request(self, *args, **kwargs):
|
||||||
|
if TestBaseRateLimiter.request_received is None:
|
||||||
|
TestBaseRateLimiter.request_received = []
|
||||||
|
TestBaseRateLimiter.request_received.append((args, kwargs))
|
||||||
|
# return bot.bot.to_dict() for the `get_me` call in `Bot.initialize`
|
||||||
|
return 200, json.dumps({"ok": True, "result": bot.bot.to_dict()}).encode()
|
||||||
|
|
||||||
|
defaults = Defaults(parse_mode=ParseMode.HTML)
|
||||||
|
test_request = TestRequest()
|
||||||
|
standard_bot = ExtBot(token=bot.token, defaults=defaults, request=test_request)
|
||||||
|
rl_bot = ExtBot(
|
||||||
|
token=bot.token,
|
||||||
|
defaults=defaults,
|
||||||
|
request=test_request,
|
||||||
|
rate_limiter=TestRateLimiter(),
|
||||||
|
)
|
||||||
|
|
||||||
|
async with standard_bot:
|
||||||
|
await standard_bot.set_my_commands(
|
||||||
|
commands=[BotCommand("test", "test")],
|
||||||
|
language_code="en",
|
||||||
|
api_kwargs={"api": "kwargs"},
|
||||||
|
)
|
||||||
|
async with rl_bot:
|
||||||
|
await rl_bot.set_my_commands(
|
||||||
|
commands=[BotCommand("test", "test")],
|
||||||
|
language_code="en",
|
||||||
|
rate_limit_args=(43, "test-1"),
|
||||||
|
api_kwargs={"api": "kwargs"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(self.rl_received) == 2
|
||||||
|
assert self.rl_received[0] == ("getMe", {}, None)
|
||||||
|
assert self.rl_received[1] == (
|
||||||
|
"setMyCommands",
|
||||||
|
dict(commands=[BotCommand("test", "test")], language_code="en", api="kwargs"),
|
||||||
|
(43, "test-1"),
|
||||||
|
)
|
||||||
|
assert len(self.request_received) == 4
|
||||||
|
# self.request_received[i] = i-th received request
|
||||||
|
# self.request_received[i][0] = i-th received request's args
|
||||||
|
# self.request_received[i][1] = i-th received request's kwargs
|
||||||
|
assert self.request_received[0][1]["url"].endswith("getMe")
|
||||||
|
assert self.request_received[2][1]["url"].endswith("getMe")
|
||||||
|
assert self.request_received[1][0] == self.request_received[3][0]
|
||||||
|
assert self.request_received[1][1].keys() == self.request_received[3][1].keys()
|
||||||
|
for key, value in self.request_received[1][1].items():
|
||||||
|
if isinstance(value, RequestData):
|
||||||
|
assert value.parameters == self.request_received[3][1][key].parameters
|
||||||
|
assert value.parameters["api"] == "kwargs"
|
||||||
|
else:
|
||||||
|
assert value == self.request_received[3][1][key]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not TEST_RATE_LIMITER, reason="Only relevant if the optional dependency is installed"
|
||||||
|
)
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
os.getenv("GITHUB_ACTIONS", False) and platform.system() == "Darwin",
|
||||||
|
reason="The timings are apparently rather inaccurate on MacOS.",
|
||||||
|
)
|
||||||
|
@flaky(10, 1) # Timings aren't quite perfect
|
||||||
|
class TestAIORateLimiter:
|
||||||
|
count = 0
|
||||||
|
call_times = []
|
||||||
|
|
||||||
|
class CountRequest(BaseRequest):
|
||||||
|
def __init__(self, retry_after=None):
|
||||||
|
self.retry_after = retry_after
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def do_request(self, *args, **kwargs):
|
||||||
|
TestAIORateLimiter.count += 1
|
||||||
|
TestAIORateLimiter.call_times.append(time.time())
|
||||||
|
if self.retry_after:
|
||||||
|
raise RetryAfter(retry_after=1)
|
||||||
|
|
||||||
|
url = kwargs.get("url").lower()
|
||||||
|
if url.endswith("getme"):
|
||||||
|
return (
|
||||||
|
HTTPStatus.OK,
|
||||||
|
json.dumps(
|
||||||
|
{"ok": True, "result": User(id=1, first_name="bot", is_bot=True).to_dict()}
|
||||||
|
).encode(),
|
||||||
|
)
|
||||||
|
if url.endswith("sendmessage"):
|
||||||
|
return (
|
||||||
|
HTTPStatus.OK,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"result": Message(
|
||||||
|
message_id=1, date=datetime.now(), chat=Chat(1, "chat")
|
||||||
|
).to_dict(),
|
||||||
|
}
|
||||||
|
).encode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset(self):
|
||||||
|
self.count = 0
|
||||||
|
TestAIORateLimiter.count = 0
|
||||||
|
self.call_times = []
|
||||||
|
TestAIORateLimiter.call_times = []
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("max_retries", [0, 1, 4])
|
||||||
|
async def test_max_retries(self, bot, max_retries):
|
||||||
|
|
||||||
|
bot = ExtBot(
|
||||||
|
token=bot.token,
|
||||||
|
request=self.CountRequest(retry_after=1),
|
||||||
|
rate_limiter=AIORateLimiter(
|
||||||
|
max_retries=max_retries, overall_max_rate=0, group_max_rate=0
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with pytest.raises(RetryAfter):
|
||||||
|
await bot.get_me()
|
||||||
|
|
||||||
|
# Check that we retried the request the correct number of times
|
||||||
|
assert TestAIORateLimiter.count == max_retries + 1
|
||||||
|
|
||||||
|
# Check that the retries were delayed correctly
|
||||||
|
times = TestAIORateLimiter.call_times
|
||||||
|
if len(times) <= 1:
|
||||||
|
return
|
||||||
|
delays = [j - i for i, j in zip(times[:-1], times[1:])]
|
||||||
|
assert delays == pytest.approx([1.1 for _ in range(max_retries)], rel=0.05)
|
||||||
|
|
||||||
|
async def test_delay_all_pending_on_retry(self, bot):
|
||||||
|
# Makes sure that a RetryAfter blocks *all* pending requests
|
||||||
|
bot = ExtBot(
|
||||||
|
token=bot.token,
|
||||||
|
request=self.CountRequest(retry_after=1),
|
||||||
|
rate_limiter=AIORateLimiter(max_retries=1, overall_max_rate=0, group_max_rate=0),
|
||||||
|
)
|
||||||
|
task_1 = asyncio.create_task(bot.get_me())
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
task_2 = asyncio.create_task(bot.get_me())
|
||||||
|
|
||||||
|
assert not task_1.done()
|
||||||
|
assert not task_2.done()
|
||||||
|
|
||||||
|
await asyncio.sleep(1.1)
|
||||||
|
assert isinstance(task_1.exception(), RetryAfter)
|
||||||
|
assert not task_2.done()
|
||||||
|
|
||||||
|
await asyncio.sleep(1.1)
|
||||||
|
assert isinstance(task_2.exception(), RetryAfter)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("group_id", [-1, "-1", "@username"])
|
||||||
|
@pytest.mark.parametrize("chat_id", [1, "1"])
|
||||||
|
async def test_basic_rate_limiting(self, bot, group_id, chat_id):
|
||||||
|
try:
|
||||||
|
rl_bot = ExtBot(
|
||||||
|
token=bot.token,
|
||||||
|
request=self.CountRequest(retry_after=None),
|
||||||
|
rate_limiter=AIORateLimiter(
|
||||||
|
overall_max_rate=1,
|
||||||
|
overall_time_period=1 / 4,
|
||||||
|
group_max_rate=1,
|
||||||
|
group_time_period=1 / 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async with rl_bot:
|
||||||
|
non_group_tasks = {}
|
||||||
|
group_tasks = {}
|
||||||
|
for i in range(4):
|
||||||
|
group_tasks[i] = asyncio.create_task(
|
||||||
|
rl_bot.send_message(chat_id=group_id, text="test")
|
||||||
|
)
|
||||||
|
for i in range(8):
|
||||||
|
non_group_tasks[i] = asyncio.create_task(
|
||||||
|
rl_bot.send_message(chat_id=chat_id, text="test")
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.85)
|
||||||
|
# We expect 5 requests:
|
||||||
|
# 1: `get_me` from `async with rl_bot`
|
||||||
|
# 2: `send_message` at time 0.00
|
||||||
|
# 3: `send_message` at time 0.25
|
||||||
|
# 4: `send_message` at time 0.50
|
||||||
|
# 5: `send_message` at time 0.75
|
||||||
|
assert TestAIORateLimiter.count == 5
|
||||||
|
assert sum(1 for task in non_group_tasks.values() if task.done()) < 8
|
||||||
|
assert sum(1 for task in group_tasks.values() if task.done()) < 4
|
||||||
|
|
||||||
|
# 3 seconds after start
|
||||||
|
await asyncio.sleep(3.1 - 0.85)
|
||||||
|
assert all(task.done() for task in non_group_tasks.values())
|
||||||
|
assert all(task.done() for task in group_tasks.values())
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# cleanup
|
||||||
|
await asyncio.gather(*non_group_tasks.values(), *group_tasks.values())
|
||||||
|
TestAIORateLimiter.count = 0
|
||||||
|
TestAIORateLimiter.call_times = []
|
||||||
|
|
||||||
|
async def test_rate_limiting_no_chat_id(self, bot):
|
||||||
|
try:
|
||||||
|
rl_bot = ExtBot(
|
||||||
|
token=bot.token,
|
||||||
|
request=self.CountRequest(retry_after=None),
|
||||||
|
rate_limiter=AIORateLimiter(
|
||||||
|
overall_max_rate=1,
|
||||||
|
overall_time_period=1 / 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async with rl_bot:
|
||||||
|
non_chat_tasks = {}
|
||||||
|
chat_tasks = {}
|
||||||
|
for i in range(4):
|
||||||
|
chat_tasks[i] = asyncio.create_task(
|
||||||
|
rl_bot.send_message(chat_id=-1, text="test")
|
||||||
|
)
|
||||||
|
for i in range(8):
|
||||||
|
non_chat_tasks[i] = asyncio.create_task(rl_bot.get_me())
|
||||||
|
|
||||||
|
await asyncio.sleep(0.6)
|
||||||
|
# We expect 11 requests:
|
||||||
|
# 1: `get_me` from `async with rl_bot`
|
||||||
|
# 2: `send_message` at time 0.00
|
||||||
|
# 3: `send_message` at time 0.05
|
||||||
|
# 4: 8 times `get_me`
|
||||||
|
assert TestAIORateLimiter.count == 11
|
||||||
|
assert sum(1 for task in non_chat_tasks.values() if task.done()) == 8
|
||||||
|
assert sum(1 for task in chat_tasks.values() if task.done()) == 2
|
||||||
|
|
||||||
|
# 1.6 seconds after start
|
||||||
|
await asyncio.sleep(1.6 - 0.6)
|
||||||
|
assert all(task.done() for task in non_chat_tasks.values())
|
||||||
|
assert all(task.done() for task in chat_tasks.values())
|
||||||
|
finally:
|
||||||
|
# cleanup
|
||||||
|
await asyncio.gather(*non_chat_tasks.values(), *chat_tasks.values())
|
||||||
|
TestAIORateLimiter.count = 0
|
||||||
|
TestAIORateLimiter.call_times = []
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("intermediate", [True, False])
|
||||||
|
async def test_group_caching(self, bot, intermediate):
|
||||||
|
try:
|
||||||
|
max_rate = 1000
|
||||||
|
rl_bot = ExtBot(
|
||||||
|
token=bot.token,
|
||||||
|
request=self.CountRequest(retry_after=None),
|
||||||
|
rate_limiter=AIORateLimiter(
|
||||||
|
overall_max_rate=max_rate,
|
||||||
|
overall_time_period=1,
|
||||||
|
group_max_rate=max_rate,
|
||||||
|
group_time_period=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unfortunately, there is no reliable way to test this without checking the internals
|
||||||
|
assert len(rl_bot.rate_limiter._group_limiters) == 0
|
||||||
|
await asyncio.gather(
|
||||||
|
*(rl_bot.send_message(chat_id=-(i + 1), text=f"{i}") for i in range(513))
|
||||||
|
)
|
||||||
|
if intermediate:
|
||||||
|
await rl_bot.send_message(chat_id=-1, text="999")
|
||||||
|
assert 1 <= len(rl_bot.rate_limiter._group_limiters) <= 513
|
||||||
|
else:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
await rl_bot.send_message(chat_id=-1, text="999")
|
||||||
|
assert len(rl_bot.rate_limiter._group_limiters) == 1
|
||||||
|
finally:
|
||||||
|
TestAIORateLimiter.count = 0
|
||||||
|
TestAIORateLimiter.call_times = []
|
Loading…
Add table
Reference in a new issue