Repository: falconry/token-bucket

Issue:
Rate cant be smaller than one
Hello,
this is more feature request rather than bug.
In the limiter there is now still check that the rate is not smaller than one. 
As rate can be float now, I believe that this test should be relaxed to allow values bigger than zero to allow for rates lower than one request per second - like for example limiting API calls to application to 10 requests per minute with rate=10.0/60

```
diff --git a/token_bucket/limiter.py b/token_bucket/limiter.py
index 06e1b5a..ea1c631 100644
--- a/token_bucket/limiter.py
+++ b/token_bucket/limiter.py
@@ -69,8 +69,8 @@ class Limiter(object):
         if not isinstance(rate, (float, int)):
             raise TypeError('rate must be an int or float')
 
-        if rate < 1:
-            raise ValueError('rate must be >= 1')
+        if rate <= 0:
+            raise ValueError('rate must be > 0')
 
         if not isinstance(capacity, int):
             raise TypeError('capacity must be an int')
```

Best regards
Michal Ambroz

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/token-bucket/pull/11?src=pr&el=h1) Report
> :exclamation: No coverage uploaded for pull request base (`master@61f4e63`). [Click here to learn what that means](https://docs.codecov.io/docs/error-reference#section-missing-base-commit).
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/token-bucket/pull/11/graphs/tree.svg?width=650&token=o5EfMwCjPl&height=150&src=pr)](https://codecov.io/gh/falconry/token-bucket/pull/11?src=pr&el=tree)

```diff
@@           Coverage Diff           @@
##             master    #11   +/-   ##
=======================================
  Coverage          ?   100%           
=======================================
  Files             ?      8           
  Lines             ?    207           
  Branches          ?      0           
=======================================
  Hits              ?    207           
  Misses            ?      0           
  Partials          ?      0
```


| [Impacted Files](https://codecov.io/gh/falconry/token-bucket/pull/11?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [tests/test\_limiter.py](https://codecov.io/gh/falconry/token-bucket/pull/11/diff?src=pr&el=tree#diff-dGVzdHMvdGVzdF9saW1pdGVyLnB5) | `100% <ø> (ø)` | |
| [token\_bucket/limiter.py](https://codecov.io/gh/falconry/token-bucket/pull/11/diff?src=pr&el=tree#diff-dG9rZW5fYnVja2V0L2xpbWl0ZXIucHk=) | `100% <100%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/token-bucket/pull/11?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/token-bucket/pull/11?src=pr&el=footer). Last update [61f4e63...068a001](https://codecov.io/gh/falconry/token-bucket/pull/11?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

lalinsky: Can this please be merged? There is no reason to restrict rate to >=1.
vytas7: Thanks for the reminder, I was totally unaware we had PRs over here 🙂 

We'll take a look at this!

Patch:
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 0000000..2b836a7
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,8 @@
+[run]
+branch = True
+source = token_bucket
+
+parallel = True
+
+[report]
+show_missing = True
diff --git a/.gitignore b/.gitignore
index 75b8d08..fca2acf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,7 +38,6 @@ pip-delete-this-directory.txt
 htmlcov/
 .tox/
 .coverage
-.coveragerc
 .coverage_html
 .coverage.*
 .cache
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index aef3e2d..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,32 +0,0 @@
-cache:
-  directories:
-  - "$HOME/.cache/pip"
-language: python
-matrix:
-  include:
-  - python: '2.7'
-    env: TOXENV=py27
-  - python: '3.4'
-    env: TOXENV=py34
-  - python: '3.5'
-    env: TOXENV=py35
-  - python: pypy-5.4
-    env: TOXENV=pypy
-  - python: '2.7'
-    env: TOXENV=pep8
-before_install:
-  - pip install --upgrade pip
-  - pip install --upgrade tox codecov
-script:
-  - tox
-after_success:
-  - coverage combine
-  - codecov
-notifications:
-  irc:
-    channels:
-      - "chat.freenode.net#falconframework"
-    on_success: change
-    on_failure: always
-    use_notice: true
-    skip_join: true
diff --git a/README.rst b/README.rst
index 27305dc..6d35c48 100644
--- a/README.rst
+++ b/README.rst
@@ -1,5 +1,7 @@
-A Token Bucket Implementation for Python Web Apps |Build Status| |codecov.io|
-=============================================================================
+|Build Status| |PyPi| |codecov.io|
+
+A Token Bucket Implementation for Python Web Apps
+=================================================
 
 The ``token-bucket`` package provides an implementation of the
 `token bucket algorithm <http://falconframework.org/index.html>`_
@@ -21,7 +23,11 @@ additional processing and memory overhead vs. the token bucket
 algorithm which uses a simple, fast counter per key. The latter approach
 does allow for bursting, but only for a controlled duration.
 
-.. |Build Status| image:: https://travis-ci.org/falconry/token-bucket.svg
-   :target: https://travis-ci.org/falconry/token-bucket
+.. |Build Status| image:: https://github.com/falconry/token-bucket/workflows/tests/badge.svg
+   :target: https://github.com/falconry/token-bucket/actions?query=workflow%3A%22tests%22
+
+.. |PyPi| image:: https://img.shields.io/pypi/v/token-bucket.svg
+   :target: https://pypi.python.org/pypi/token-bucket
+
 .. |codecov.io| image:: https://codecov.io/gh/falconry/token-bucket/branch/master/graph/badge.svg
    :target: https://codecov.io/gh/falconry/token-bucket
diff --git a/setup.py b/setup.py
index 31e9d5c..ab4e061 100644
--- a/setup.py
+++ b/setup.py
@@ -28,16 +28,18 @@ setup(
         'Programming Language :: Python',
         'Programming Language :: Python :: Implementation :: CPython',
         'Programming Language :: Python :: Implementation :: PyPy',
-        'Programming Language :: Python :: 2.7',
-        'Programming Language :: Python :: 3.3',
-        'Programming Language :: Python :: 3.4',
         'Programming Language :: Python :: 3.5',
+        'Programming Language :: Python :: 3.6',
+        'Programming Language :: Python :: 3.7',
+        'Programming Language :: Python :: 3.8',
+        'Programming Language :: Python :: 3.9',
     ],
     keywords='web http https cloud rate limiting token bucket throttling',
     author='kgriffs',
     url='https://github.com/falconry/token-bucket',
     license='Apache 2.0',
     packages=find_packages(exclude=['tests']),
+    python_requires='>=3.5',
     install_requires=[],
     setup_requires=['pytest-runner'],
     tests_require=['pytest'],
diff --git a/token_bucket/limiter.py b/token_bucket/limiter.py
index 06e1b5a..ea1c631 100644
--- a/token_bucket/limiter.py
+++ b/token_bucket/limiter.py
@@ -69,8 +69,8 @@ class Limiter(object):
         if not isinstance(rate, (float, int)):
             raise TypeError('rate must be an int or float')
 
-        if rate < 1:
-            raise ValueError('rate must be >= 1')
+        if rate <= 0:
+            raise ValueError('rate must be > 0')
 
         if not isinstance(capacity, int):
             raise TypeError('capacity must be an int')
diff --git a/token_bucket/storage.py b/token_bucket/storage.py
index 07cf7fa..9a93093 100644
--- a/token_bucket/storage.py
+++ b/token_bucket/storage.py
@@ -96,7 +96,7 @@ class MemoryStorage(StorageBase):
 
             tokens_in_bucket, last_replenished_at = self._buckets[key]
 
-            now = time.time()
+            now = time.monotonic()
 
             # NOTE(kgriffs): This will detect many, but not all,
             #   manifestations of the race condition. If a later
@@ -123,7 +123,7 @@ class MemoryStorage(StorageBase):
             ]
 
         except KeyError:
-            self._buckets[key] = [capacity, time.time()]
+            self._buckets[key] = [capacity, time.monotonic()]
 
     def consume(self, key, num_tokens):
         """Attempt to take one or more tokens from a bucket.
diff --git a/tox.ini b/tox.ini
index 34246c8..777799b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,14 +1,11 @@
 [tox]
-envlist = py27,
-          py34,
-          py35,
-          pypy,
-          pep8,
+envlist = pep8,
+          py38,
           coverage-report
 
 [testenv]
 deps = -r{toxinidir}/requirements/tests
-commands = coverage run --parallel -m pytest {posargs:tests}
+commands = coverage run -m pytest {posargs:tests}
 
 # --------------------------------------------------------------------
 # Style
@@ -23,14 +20,10 @@ application-import-names = token_bucket
 exclude = .tox,.venv,dist,docs,*.egg,.git,_build,build
 
 [testenv:pep8]
+skip_install = true
 deps = flake8
        flake8-quotes
        flake8-import-order
-
-# NOTE(kgriffs): Run with py27 in case any code branches assume the
-#   unicode type is defined, which will cause pep8 complains in those
-#   running under py3.
-basepython = python2.7
 commands = flake8 {posargs:.}
 
 # --------------------------------------------------------------------


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
new file mode 100644
index 0000000..381cd4b
--- /dev/null
+++ b/.github/workflows/tests.yaml
@@ -0,0 +1,87 @@
+name: tests
+
+on:
+  # Trigger the tests workflow on push or pull requests.
+  push:
+    branches:
+      - "*"
+  pull_request:
+    branches:
+      - master
+
+jobs:
+  run_tox:
+    name: tox -e ${{ matrix.toxenv }} (${{matrix.python-version}} on ${{ matrix.os }})
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version:
+          - "3.8"
+        os:
+          - "ubuntu-20.04"
+        toxenv:
+          - "pep8"
+        include:
+          - python-version: 3.5
+            os: ubuntu-16.04
+            toxenv: py35
+          - python-version: 3.6
+            os: ubuntu-20.04
+            toxenv: py36
+          - python-version: 3.7
+            os: ubuntu-20.04
+            toxenv: py37
+          - python-version: 3.8
+            os: ubuntu-20.04
+            toxenv: py38
+          - python-version: 3.9
+            os: ubuntu-20.04
+            toxenv: py39
+          - python-version: pypy3
+            os: ubuntu-20.04
+            toxenv: pypy3
+
+    # Steps to run in each job.
+    # Some are GitHub actions, others run shell commands.
+    steps:
+      - name: Checkout repo
+        uses: actions/checkout@v2
+
+      - name: Set up Python
+        uses: actions/setup-python@v2
+        if: ${{ matrix.toxenv != 'py35' }}
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Set up Python 3.8
+        uses: actions/setup-python@v2.1.4
+        if: ${{ matrix.toxenv == 'py35' }}
+        with:
+          python-version: 3.8
+
+      - name: Set up Python 3.5
+        if: ${{ matrix.toxenv == 'py35' }}
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y build-essential curl python3.5 python3.5-dev
+          python3.5 --version
+
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install coverage tox
+          python --version
+          pip --version
+          tox --version
+          coverage --version
+
+      - name: Run tests
+        run: tox -e ${{ matrix.toxenv }}
+
+      - name: Upload coverage to Codecov
+        uses: codecov/codecov-action@v1
+        if: ${{ matrix.toxenv == 'py38' }}
+        with:
+          env_vars: PYTHON
+          fail_ci_if_error: true
diff --git a/tests/test_limiter.py b/tests/test_limiter.py
index b0fe72c..aabaaed 100644
--- a/tests/test_limiter.py
+++ b/tests/test_limiter.py
@@ -8,6 +8,7 @@ import token_bucket
 
 
 @pytest.mark.parametrize('rate,capacity', [
+    (0.3, 1),
     (1, 1),
     (2.5, 1),  # Fractional rates are valid
     (10, 100),  # Long recovery time after bursting