Repository: stravalib/stravalib

Issue:
Upcoming changes to API rate limiting
Strava's new [developer program](https://communityhub.strava.com/t5/developer-knowledge-base/our-developer-program/ta-p/8849) introduces a new "read rate limit", which is for specific endpoints. This means that our existing rate limiters should be modified to:

1. Handle the new headers from a response
2. Execute based on which endpoint is called (which requires a redesign of our rate limiter approach)

It is not clear when Strava will enforce these new rules.

Discussion:
lwasser: @jsamoocha just so i understand. it looks like the rate limiter should be called as it was previously. but this just adapts to strava's new implementation? previously i had a hard time with getting it to work as you mentioned in an issue about this so i wanted to ask that first before i play with the pr.

also it looks like all of our checks are  green and the [docs build](https://stravalib--446.org.readthedocs.build/en/446/) are you still seeing documentation errors locally?
lwasser: ahhh i see [these warnings](https://github.com/stravalib/stravalib/actions/runs/6628660521/job/18006243504?pr=446#step:8:185) - perhaps that is what you're seeing locally?
jsamoocha: > it looks like the rate limiter should be called as it was previously. but this just adapts to strava's new implementation?

I added a small section to the documentation on using and configuring rate limiting. Existing users or client apps that didn't do any customization re. rate limiters will experience no changes other than client instances blocking instead of raising exceptions when rate limits are reached.

Whoever uses custom rate limiter classes has to change its `__call__()` method to accept the additional `method` argument. This is something we need to document when making the release.

I hope this clarifies.
enadeau: Great update of this whole ratelimiter buisness that was really needed! I think the breaking change should be mentioned in the changelog. It feels like the best place to put it
jsamoocha: Thanks for reviewing @lwasser ! Somehow, adding your changes to the docs made mypy unhappy. Locally, it runs fine:

```
❯ python --version
Python 3.10.10
❯ nox -s mypy
nox > Running session mypy
nox > Re-using existing virtual environment at .nox/mypy.
nox > python -m pip install '.[all]'
nox > python -m pip install -r requirements.txt
nox > mypy
Success: no issues found in 12 source files
nox > Session mypy was successful.
```

Any idea what's causing this? If you're ok with it, I can still merge while ignoring the issue.
enadeau: I just took a look at it and I suspect you are not running the newest version of mypy locally while CI is. If you update your local version you should get the warning.

 The warning are however unrelated to PR so we can save here and address them in an other PR
jsamoocha: Thanks for checking, @enadeau: it was indeed a new version of mypy causing the warnings. I'll merge now.

Patch:
diff --git a/changelog.md b/changelog.md
index 22b6d9d..d127d7c 100644
--- a/changelog.md
+++ b/changelog.md
@@ -6,7 +6,14 @@
 - Fix: Type annotation on field that are of type timedelta are now correct (@enadeau, #440)
 - Fix: Correct type for ActivityPhoto sizes attribute (@jsamoocha, #444)
 - Fix: codespell config - ignore resources dir in tests (@lwasser, #445)
-- Fix: ignore documentation autogenerated api stubs (@lwasser, #)
+- Add: Support for Strava's new read rate limits (@jsamoocha, #446)
+- Fix: ignore documentation autogenerated api stubs (@lwasser, #447)
+
+### Breaking Changes
+
+A potentially breaking change is that the RateLimiter's `__call__()` method now has
+an additional `method` argument representing the HTTP method used for that request.
+Existing custom rate limiters from users must be updated to this change.
 
 ## v1.5
 
diff --git a/docs/get-started/overview.rst b/docs/get-started/overview.rst
index 50f23cf..5a41220 100644
--- a/docs/get-started/overview.rst
+++ b/docs/get-started/overview.rst
@@ -124,3 +124,30 @@ or just cast to a numeric type (e.g. float).::
    # 22530.8
    print(float(unithelper.miles(activity.distance)))
    # 13.9999900581
+
+
+Rate Limits
+===========
+
+Strava imposes rate limits on the usage of its API. This means that the number of
+requests sent to Strava have an upper limit per 15 minutes and per day. These limits
+are not fixed but depend on the "size" of the client app. Strava _may_ choose to
+adjust rate limits for apps as they grow. [Learn more about rate limits here.](https://developers.strava.com/docs/rate-limits/)
+You can see the limits set for your app at [your account's settings.](https://www.strava.com/settings/api)
+
+When initializing a `stravalib.Client` instance, the default rate limiter allows requests until
+the short - or daily limits are reached. Once limits are reached. the client object will wait until the end of
+the 15-minute or day period.
+
+In case you want to configure the limiter to throttle requests (i.e., making sure
+the time between requests for the remaining period is evenly spread), you can
+initialize the client object as::
+
+   from stravalib.util.limiter import DefaultRateLimiter
+    client = stravalib.Client(
+        my_access_token, rate_limiter=DefaultRateLimiter(priority='medium')
+    )
+
+
+The ``low`` priority complies with the daily limit. The ``medium`` priority ensures that requests are throttled to comply with the
+15-minute limit.
diff --git a/docs/reference/field-conversions.rst b/docs/reference/field-conversions.rst
index 01e5c5f..a2b9fb2 100644
--- a/docs/reference/field-conversions.rst
+++ b/docs/reference/field-conversions.rst
@@ -15,5 +15,4 @@ Summary Functions
    optional_input
    enum_value
    enum_values
-   time_interval
    timezone
diff --git a/docs/reference/utilities.rst b/docs/reference/utilities.rst
index 355d2fd..5e78032 100644
--- a/docs/reference/utilities.rst
+++ b/docs/reference/utilities.rst
@@ -21,11 +21,7 @@ Summary Classes
 .. autosummary::
    :toctree: api/
 
-   XRateLimitRule
    SleepingRateLimitRule
-   RateLimitRule
    RateLimiter
    DefaultRateLimiter
    RequestRate
-   LimitStructure
-   LimitsStructure
diff --git a/src/stravalib/protocol.py b/src/stravalib/protocol.py
index 18846af..a2b5f6a 100644
--- a/src/stravalib/protocol.py
+++ b/src/stravalib/protocol.py
@@ -27,6 +27,8 @@ Scope = Literal[
     "activity:write",
 ]
 
+RequestMethod = Literal["GET", "POST", "PUT", "DELETE"]
+
 
 class AccessInfo(TypedDict):
     """Dictionary containing token exchange response from Strava."""
@@ -57,7 +59,8 @@ class ApiV3(metaclass=abc.ABCMeta):
         self,
         access_token: str | None = None,
         requests_session: requests.Session | None = None,
-        rate_limiter: Callable[[dict[str, str]], None] | None = None,
+        rate_limiter: Callable[[dict[str, str], RequestMethod], None]
+        | None = None,
     ):
         """Initialize this protocol client, optionally providing a (shared)
         :class:`requests.Session` object.
@@ -79,7 +82,9 @@ class ApiV3(metaclass=abc.ABCMeta):
         else:
             self.rsession = requests.Session()
 
-        self.rate_limiter = rate_limiter or (lambda _: None)
+        self.rate_limiter = rate_limiter or (
+            lambda _request_params, _method: None
+        )
 
     def authorization_url(
         self,
@@ -261,7 +266,7 @@ class ApiV3(metaclass=abc.ABCMeta):
         url: str,
         params: dict[str, Any] | None = None,
         files: dict[str, SupportsRead[str | bytes]] | None = None,
-        method: Literal["GET", "POST", "PUT", "DELETE"] = "GET",
+        method: RequestMethod = "GET",
         check_for_errors: bool = True,
     ) -> Any:
         """Perform the underlying request, returning the parsed JSON results.
@@ -314,7 +319,7 @@ class ApiV3(metaclass=abc.ABCMeta):
         raw = requester(url, params=params)  # type: ignore[operator]
         # Rate limits are taken from HTTP response headers
         # https://developers.strava.com/docs/rate-limits/
-        self.rate_limiter(raw.headers)
+        self.rate_limiter(raw.headers, method)
 
         if check_for_errors:
             self._handle_protocol_error(raw)
diff --git a/src/stravalib/util/limiter.py b/src/stravalib/util/limiter.py
index 794a91a..0df3bb5 100644
--- a/src/stravalib/util/limiter.py
+++ b/src/stravalib/util/limiter.py
@@ -21,16 +21,14 @@ From the Strava docs:
 """
 from __future__ import annotations
 
-import collections
 import logging
 import time
-from datetime import datetime, timedelta
 from logging import Logger
-from typing import Callable, Deque, Literal, NamedTuple, NoReturn, TypedDict
+from typing import Callable, Literal, NamedTuple
 
 import arrow
 
-from stravalib import exc
+from stravalib.protocol import RequestMethod
 
 
 class RequestRate(NamedTuple):
@@ -50,7 +48,7 @@ class RequestRate(NamedTuple):
 
 
 def get_rates_from_response_headers(
-    headers: dict[str, str]
+    headers: dict[str, str], method: RequestMethod
 ) -> RequestRate | None:
     """Returns a namedtuple with values for short - and long usage and limit
     rates found in provided HTTP response headers
@@ -59,6 +57,8 @@ def get_rates_from_response_headers(
     ----------
     headers : dict
         HTTP response headers
+    method : RequestMethod
+        HTTP request method corresponding to the provided response headers
 
     Returns
     -------
@@ -66,17 +66,27 @@ def get_rates_from_response_headers(
         namedtuple with request rates or None if no rate-limit headers
         present in response.
     """
-    try:
+    usage_rates = limit_rates = []
+
+    if "X-ReadRateLimit-Usage" in headers and method == "GET":
+        usage_rates = [
+            int(v) for v in headers["X-ReadRateLimit-Usage"].split(",")
+        ]
+        limit_rates = [
+            int(v) for v in headers["X-ReadRateLimit-Limit"].split(",")
+        ]
+    elif "X-RateLimit-Usage" in headers:
         usage_rates = [int(v) for v in headers["X-RateLimit-Usage"].split(",")]
         limit_rates = [int(v) for v in headers["X-RateLimit-Limit"].split(",")]
 
+    if usage_rates and limit_rates:
         return RequestRate(
             short_usage=usage_rates[0],
             long_usage=usage_rates[1],
             short_limit=limit_rates[0],
             long_limit=limit_rates[1],
         )
-    except KeyError:
+    else:
         return None
 
 
@@ -128,147 +138,6 @@ def get_seconds_until_next_day(now: arrow.arrow.Arrow | None = None) -> int:
     return (now.ceil("day") - now).seconds
 
 
-class LimitStructure(TypedDict):
-    """Dictionary that holds rate limits for a specific time window size."""
-
-    usage: int
-    """Usage in the current period"""
-
-    limit: int
-    """Limit for the period"""
-
-    time: float
-    """Duration of the period"""
-
-    lastExceeded: datetime | None
-    """Last time the limit was exceeded"""
-
-
-class LimitsStructure(TypedDict):
-    """Dictionary that holds rate limiting information for both of Strava
-    15-minutes and
-    daily rate limits.
-    """
-
-    short: LimitStructure
-    """15-minutes rate limiting data"""
-
-    long: LimitStructure
-    """Daily rate limiting data"""
-
-
-class XRateLimitRule:
-    def __init__(
-        self, limits: LimitsStructure, force_limits: bool = False
-    ) -> None:
-        """
-        Parameters
-        ----------
-        limits : LimitsStructure
-            The limits structure.
-        force_limits : bool (default=False)
-            If False (default), this rule will set/update its limits
-            based on what the Strava API tells it. If True, the provided
-            limits will be enforced, i.e. ignoring the limits given by
-            the API.
-        """
-        self.log = logging.getLogger(
-            "{0.__module__}.{0.__name__}".format(self.__class__)
-        )
-        self.rate_limits = limits
-        # TODO: Should limit args be validated?
-        self.limit_time_invalid: float = 0.0
-        self.force_limits = force_limits
-
-    @property
-    def limit_timeout(self) -> float:
-        return self.limit_time_invalid
-
-    def __call__(self, response_headers: dict[str, str]) -> None:
-        """Check limit rates for API calls and update current usage state
-
-        Parameters
-        ----------
-        response_headers : dict
-            A dictionary of strings from the response providing the current
-            rate-limit state.
-
-        Returns
-        -------
-        None
-            Updates the object's rate limit state attrs.
-        """
-        self._update_usage(response_headers)
-
-        self._check_limit_time_invalid(self.rate_limits["short"])
-        self._check_limit_rates(self.rate_limits["short"])
-        self._check_limit_time_invalid(self.rate_limits["long"])
-        self._check_limit_rates(self.rate_limits["long"])
-
-    def _update_usage(self, response_headers: dict[str, str]) -> None:
-        """Update current state of rate limit usage."""
-        rates = get_rates_from_response_headers(response_headers)
-
-        if rates:
-            self.log.debug(
-                "Updating rate-limit limits and usage from headers: {}".format(
-                    rates
-                )
-            )
-            self.rate_limits["short"]["usage"] = rates.short_usage
-            self.rate_limits["long"]["usage"] = rates.long_usage
-
-            if not self.force_limits:
-                self.rate_limits["short"]["limit"] = rates.short_limit
-                self.rate_limits["long"]["limit"] = rates.long_limit
-
-    def _check_limit_rates(self, limit: LimitStructure) -> None:
-        """Check the current limit rates for API calls."""
-        if limit["usage"] >= limit["limit"]:
-            self.log.debug("Rate limit of {} reached.".format(limit["limit"]))
-            limit["lastExceeded"] = datetime.now()
-            self._raise_rate_limit_exception(limit["limit"], limit["time"])
-
-    def _check_limit_time_invalid(self, limit: LimitStructure) -> None:
-        self.limit_time_invalid = 0
-        if limit["lastExceeded"] is not None:
-            delta = (datetime.now() - limit["lastExceeded"]).total_seconds()
-            if delta < limit["time"]:
-                self.limit_time_invalid = limit["time"] - delta
-                self.log.debug(
-                    "Rate limit invalid duration {} seconds.".format(
-                        self.limit_time_invalid
-                    )
-                )
-                self._raise_rate_limit_timeout(
-                    self.limit_timeout, limit["limit"]
-                )
-
-    def _raise_rate_limit_exception(
-        self, timeout: float, limit_rate: float
-    ) -> NoReturn:
-        """Raises an error for the user to see they have exceeded API
-        request limits"""
-        raise exc.RateLimitExceeded(
-            "Rate limit of {} exceeded. "
-            "Try again in {} seconds.".format(limit_rate, timeout),
-            limit=limit_rate,
-            timeout=timeout,
-        )
-
-    def _raise_rate_limit_timeout(
-        self, timeout: float, limit_rate: float
-    ) -> NoReturn:
-        """Inform user that rate limit exceeded and tell them when they can
-        try again."""
-        raise exc.RateLimitTimeout(
-            "Rate limit of {} exceeded. "
-            "Try again in {} seconds.".format(limit_rate, timeout),
-            limit=limit_rate,
-            timeout=timeout,
-        )
-
-
 class SleepingRateLimitRule:
     """A rate limit rule that can be prioritized and can dynamically adapt its
     limits based on API responses. Given its priority, it will enforce a
@@ -280,146 +149,73 @@ class SleepingRateLimitRule:
     def __init__(
         self,
         priority: Literal["low", "medium", "high"] = "high",
-        short_limit: int = 10000,
-        long_limit: int = 1000000,
-        force_limits: bool = False,
     ) -> None:
         """
         Constructs a new SleepingRateLimitRule.
 
         Parameters
         ----------
-        priority : str
+        priority : Literal["low", "medium", "high"]
             The priority for this rule. When 'low', the cool-down period
             after each request will be such that the long-term limits will
             not be exceeded. When 'medium', the cool-down period will be such
             that the short-term limits will not be exceeded.  When 'high',
             there will be no cool-down period.
-        short_limit : int
-            (Optional) explicit short-term limit
-        long_limit : int
-            (Optional) explicit long-term limit
-        force_limits
-            If False (default), this rule will set/update its limits
-            based on what the Strava API tells it.
-            If True, the provided limits will be enforced, i.e. ignoring the limits
-            given by the API.
         """
         if priority not in ["low", "medium", "high"]:
             raise ValueError(
-                'Invalid priority "{}", expecting one of "low", "medium" or "high"'.format(
-                    priority
-                )
+                f'Invalid priority "{priority}", expecting one of "low", "medium" or "high"'
             )
 
         self.log = logging.getLogger(
             "{0.__module__}.{0.__name__}".format(self.__class__)
         )
         self.priority = priority
-        self.short_limit = short_limit
-        self.long_limit = long_limit
-        self.force_limits = force_limits
 
     def _get_wait_time(
         self,
-        short_usage: int,
-        long_usage: int,
+        rates: RequestRate,
         seconds_until_short_limit: int,
         seconds_until_long_limit: int,
     ) -> float:
         """Calculate how much time user has until they can make another
         request"""
 
-        if long_usage >= self.long_limit:
+        if rates.long_usage >= rates.long_limit:
             self.log.warning("Long term API rate limit exceeded")
             return seconds_until_long_limit
-        elif short_usage >= self.short_limit:
+        elif rates.short_usage >= rates.short_limit:
             self.log.warning("Short term API rate limit exceeded")
             return seconds_until_short_limit
 
         if self.priority == "high":
             return 0
         elif self.priority == "medium":
-            return seconds_until_short_limit / (self.short_limit - short_usage)
+            return seconds_until_short_limit / (
+                rates.short_limit - rates.short_usage
+            )
         elif self.priority == "low":
-            return seconds_until_long_limit / (self.long_limit - long_usage)
+            return seconds_until_long_limit / (
+                rates.long_limit - rates.long_usage
+            )
 
-    def __call__(self, response_headers: dict[str, str]) -> None:
-        """Determines wait time until a call can be mde again"""
-        rates = get_rates_from_response_headers(response_headers)
+    def __call__(
+        self, response_headers: dict[str, str], method: RequestMethod
+    ) -> None:
+        """Determines wait time until a call can be made again"""
+        rates = get_rates_from_response_headers(response_headers, method)
+        self.log.debug(f"Throttling based on rates: {rates}")
 
         if rates:
             time.sleep(
                 self._get_wait_time(
-                    rates.short_usage,
-                    rates.long_usage,
+                    rates,
                     get_seconds_until_next_quarter(),
                     get_seconds_until_next_day(),
                 )
             )
-            if not self.force_limits:
-                self.short_limit = rates.short_limit
-                self.long_limit = rates.long_limit
-
-
-class RateLimitRule:
-    def __init__(
-        self, requests: int, seconds: float, raise_exc: bool = False
-    ) -> None:
-        """
-        Parameters
-        ----------
-        requests
-            Number of requests for limit.
-        seconds
-            The number of seconds for that number of requests (may be
-            float)
-        raise_exc
-            Whether to raise an exception when limit is reached (as
-            opposed to pausing)
-        """
-        self.log = logging.getLogger(
-            "{0.__module__}.{0.__name__}".format(self.__class__)
-        )
-        self.timeframe = timedelta(seconds=seconds)
-        self.requests = requests
-        self.tab: Deque[datetime] = collections.deque(maxlen=self.requests)
-        self.raise_exc = raise_exc
-
-    def __call__(self, args: dict[str, str]) -> None:
-        """Register another request is being issued.
-
-        Depending on configuration of the rule will pause if rate limit has
-        been reached, or raise exception, etc.
-        """
-        # First check if the deque is full; that indicates that we'd better
-        # check whether we need to pause.
-        if len(self.tab) == self.requests:
-            # Grab the oldest (leftmost) timestamp and check to see if it is
-            # greater than 1 second
-            delta = datetime.now() - self.tab[0]
-            if (
-                delta < self.timeframe
-            ):  # Has it been less than configured timeframe since oldest
-                # request?
-                if self.raise_exc:
-                    raise exc.RateLimitExceeded(
-                        "Rate limit exceeded (can try again in {})".format(
-                            self.timeframe - delta
-                        )
-                    )
-                else:
-                    # Wait the difference between timeframe and the oldest
-                    # request.
-                    td = self.timeframe - delta
-                    sleeptime = td.total_seconds()
-                    self.log.debug(
-                        "Rate limit triggered; sleeping for {}".format(
-                            sleeptime
-                        )
-                    )
-                    time.sleep(sleeptime)
-        self.tab.append(datetime.now())
+        else:
+            self.log.warning("No rates present in response headers")
 
 
 class RateLimiter:
@@ -427,57 +223,39 @@ class RateLimiter:
         self.log: Logger = logging.getLogger(
             "{0.__module__}.{0.__name__}".format(self.__class__)
         )
-        self.rules: list[Callable[[dict[str, str]], None]] = []
+        self.rules: list[Callable[[dict[str, str], RequestMethod], None]] = []
 
-    def __call__(self, args: dict[str, str]) -> None:
+    def __call__(self, args: dict[str, str], method: RequestMethod) -> None:
         """Register another request is being issued."""
         for r in self.rules:
-            r(args)
+            r(args, method)
 
 
 class DefaultRateLimiter(RateLimiter):
     """Implements something similar to the default rate limit for Strava apps.
 
-    To do this correctly we would actually need to change our logic to reset
-    the limit at midnight, etc.  Will make this more complex in the future.
+    See https://developers.strava.com/docs/rate-limits/ and
+    https://communityhub.strava.com/t5/developer-knowledge-base/our-developer-program/ta-p/8849.
 
-    Strava API usage is limited on a per-application basis using a short term,
-    15 minute, limit and a long term, daily, limit. The default rate limit
-    allows 600 requests every 15 minutes, with up to 30,000 requests per day.
+    Rate limits are enforced by throttling requests based on their method and
+    client/app-specific limits imposed by Strava.
     """
 
-    def __init__(self) -> None:
-        """Strava API usage is limited on a per-application basis using a short
-        term, 15 minute, limit and a long term, daily, limit. The default rate
-        limit allows 600 requests every 15 minutes, with up to 30,000 requests
-        per day. This limit allows applications to make 40 requests per minute
-        for about half the day.
+    def __init__(
+        self, priority: Literal["low", "medium", "high"] = "high"
+    ) -> None:
+        """
+        Initializes the rate limiter based on the given priority.
+
+        Parameters
+        ----------
+        priority : Literal["low", "medium", "high"]
+            The priority given to the requests. Default is "high"
+            (i.e. no throttling).
         """
 
         super().__init__()
 
-        self.rules.append(
-            XRateLimitRule(
-                {
-                    "short": {
-                        "usage": 0,
-                        # 60s * 15 = 15 min
-                        "limit": 600,
-                        "time": (60 * 15),
-                        "lastExceeded": None,
-                    },
-                    "long": {
-                        "usage": 0,
-                        # 60s * 60m * 24 = 1 day
-                        "limit": 30000,
-                        "time": (60 * 60 * 24),
-                        "lastExceeded": None,
-                    },
-                }
-            )
-        )
+        self.rules.append(SleepingRateLimitRule(priority=priority))
 
         # TODO: This should be added to our documentation
-        # XRateLimitRule used instead of timer based RateLimitRule
-        # self.rules.append(RateLimitRule(requests=40, seconds=60, raise_exc=False))
-        # self.rules.append(RateLimitRule(requests=30000, seconds=(3600 * 24), raise_exc=True))


Test patch:
diff --git a/src/stravalib/tests/unit/test_limiter.py b/src/stravalib/tests/unit/test_limiter.py
index 624b429..b7ef241 100644
--- a/src/stravalib/tests/unit/test_limiter.py
+++ b/src/stravalib/tests/unit/test_limiter.py
@@ -1,15 +1,18 @@
 import arrow
+import pytest
 
-from stravalib.tests import TestBase
 from stravalib.util.limiter import (
+    RequestRate,
     SleepingRateLimitRule,
-    XRateLimitRule,
     get_rates_from_response_headers,
     get_seconds_until_next_day,
     get_seconds_until_next_quarter,
 )
 
-test_response = {
+# Example responses for Strava 3rd party apps that are not enrolled in the
+# 2023 developer program:
+
+fake_response_unenrolled = {
     "Status": "404 Not Found",
     "X-Request-Id": "a1a4a4973962ffa7e0f18d7c485fe741",
     "Content-Encoding": "gzip",
@@ -24,7 +27,11 @@ test_response = {
     "X-RateLimit-Usage": "4,67",
 }
 
-test_response_no_rates = {
+fake_response_unenrolled_limit_exceeded = fake_response_unenrolled | {
+    "X-RateLimit-Usage": "601, 602"
+}
+
+fake_response_unenrolled_no_rates = {
     "Status": "200 OK",
     "X-Request-Id": "d465159561420f6e0239dc24429a7cf3",
     "Content-Encoding": "gzip",
@@ -37,166 +44,96 @@ test_response_no_rates = {
     "Content-Type": "application/json; charset=UTF-8",
 }
 
+# Example responses for Strava 3rd party apps that are enrolled:
+fake_reponse_enrolled = fake_response_unenrolled | {
+    "X-ReadRateLimit-Usage": "2,32",
+    "X-ReadRateLimit-Limit": "300,15000",
+}
 
-class LimiterTest(TestBase):
-    def test_get_rates_from_response_headers(self):
-        """Should return namedtuple with rates"""
-        request_rates = get_rates_from_response_headers(test_response)
-        self.assertEqual(600, request_rates.short_limit)
-        self.assertEqual(30000, request_rates.long_limit)
-        self.assertEqual(4, request_rates.short_usage)
-        self.assertEqual(67, request_rates.long_usage)
-
-    def test_get_rates_from_response_headers_missing_rates(self):
-        """Should return namedtuple with None values for rates in case of missing rates in headers"""
-        self.assertIsNone(
-            get_rates_from_response_headers(test_response_no_rates)
-        )
-
-    def test_get_seconds_until_next_quarter(self):
-        """Should return number of seconds to next quarter of an hour"""
-        self.assertEqual(
-            59,
-            get_seconds_until_next_quarter(
-                arrow.get(2017, 11, 1, 17, 14, 0, 0)
-            ),
-        )
-        self.assertEqual(
-            59,
-            get_seconds_until_next_quarter(
-                arrow.get(2017, 11, 1, 17, 59, 0, 0)
-            ),
-        )
-        self.assertEqual(
-            0,
-            get_seconds_until_next_quarter(
-                arrow.get(2017, 11, 1, 17, 59, 59, 999999)
-            ),
-        )
-        self.assertEqual(
-            899,
-            get_seconds_until_next_quarter(
-                arrow.get(2017, 11, 1, 17, 0, 0, 1)
-            ),
-        )
-
-    def test_get_seconds_until_next_day(self):
-        """Should return the number of seconds until next day"""
-        self.assertEqual(
-            59,
-            get_seconds_until_next_day(arrow.get(2017, 11, 1, 23, 59, 0, 0)),
-        )
-        self.assertEqual(
-            86399,
-            get_seconds_until_next_day(arrow.get(2017, 11, 1, 0, 0, 0, 0)),
-        )
-
-
-class XRateLimitRuleTest(TestBase):
-    def test_rule_normal_response(self):
-        rule = XRateLimitRule(
-            {
-                "short": {
-                    "usage": 0,
-                    "limit": 600,
-                    "time": (60 * 15),
-                    "lastExceeded": None,
-                },
-                "long": {
-                    "usage": 0,
-                    "limit": 30000,
-                    "time": (60 * 60 * 24),
-                    "lastExceeded": None,
-                },
-            }
-        )
-        rule(test_response)
-        self.assertEqual(4, rule.rate_limits["short"]["usage"])
-        self.assertEqual(67, rule.rate_limits["long"]["usage"])
-
-    def test_rule_missing_rates_response(self):
-        rule = XRateLimitRule(
-            {
-                "short": {
-                    "usage": 0,
-                    "limit": 600,
-                    "time": (60 * 15),
-                    "lastExceeded": None,
-                },
-                "long": {
-                    "usage": 0,
-                    "limit": 30000,
-                    "time": (60 * 60 * 24),
-                    "lastExceeded": None,
-                },
-            }
-        )
-        rule(test_response_no_rates)
-        self.assertEqual(0, rule.rate_limits["short"]["usage"])
-        self.assertEqual(0, rule.rate_limits["long"]["usage"])
+fake_reponse_enrolled_read_limit_exceeded = fake_reponse_enrolled | {
+    "X-ReadRateLimit-Usage": "301,302"
+}
+fake_reponse_enrolled_overall_limit_exceeded = fake_reponse_enrolled | {
+    "X-RateLimit-Usage": "601,602"
+}
 
 
-class SleepingRateLimitRuleTest(TestBase):
-    def setUp(self):
-        self.test_response = test_response.copy()
+@pytest.mark.parametrize(
+    "headers,request_method,expected_rates",
+    # rates = (short_usage,long_usage,short_limit,long_limit)
+    (
+        (fake_response_unenrolled, "GET", (4, 67, 600, 30000)),
+        (fake_response_unenrolled, "POST", (4, 67, 600, 30000)),
+        (fake_response_unenrolled_no_rates, "GET", None),
+        (fake_response_unenrolled_no_rates, "PUT", None),
+        (fake_reponse_enrolled, "GET", (2, 32, 300, 15000)),
+        (fake_reponse_enrolled, "PUT", (4, 67, 600, 30000)),
+    ),
+)
+def test_get_rates_from_response_headers(
+    headers, request_method, expected_rates
+):
+    assert (
+        get_rates_from_response_headers(headers, request_method)
+        == expected_rates
+    )
+
+
+@pytest.mark.parametrize(
+    "timestamp,expected_seconds",
+    (
+        (arrow.get(2017, 11, 1, 17, 14, 0, 0), 59),
+        (arrow.get(2017, 11, 1, 17, 59, 0, 0), 59),
+        (arrow.get(2017, 11, 1, 17, 59, 59, 999999), 0),
+        (arrow.get(2017, 11, 1, 17, 0, 0, 1), 899),
+    ),
+)
+def test_get_seconds_until_next_quarter(timestamp, expected_seconds):
+    assert get_seconds_until_next_quarter(timestamp) == expected_seconds
 
-    def test_invalid_priority(self):
-        """Should raise ValueError in case of invalid priority"""
-        with self.assertRaises(ValueError):
-            SleepingRateLimitRule(priority="foobar")
 
-    def test_get_wait_time_high_priority(self):
-        """Should never sleep/wait after high priority requests"""
-        self.assertEqual(
-            0, SleepingRateLimitRule()._get_wait_time(42, 42, 60, 3600)
+@pytest.mark.parametrize(
+    "timestamp,expected_seconds",
+    (
+        (arrow.get(2017, 11, 1, 23, 59, 0, 0), 59),
+        (arrow.get(2017, 11, 1, 0, 0, 0, 0), 86399),
+    ),
+)
+def test_get_seconds_until_next_day(timestamp, expected_seconds):
+    assert get_seconds_until_next_day(timestamp) == expected_seconds
+
+
+def test_create_limiter_invalid_priority():
+    """Should raise ValueError in case of invalid priority"""
+    with pytest.raises(ValueError):
+        SleepingRateLimitRule(priority="foobar")
+
+
+@pytest.mark.parametrize(
+    "priority,rates,seconds_until_short_limit,seconds_until_long_limit,expected_wait_time",
+    (
+        ("high", RequestRate(42, 42, 100, 100), 60, 3600, 0),
+        ("medium", RequestRate(1, 1, 11, 100), 10, 1000, 1),
+        ("medium", RequestRate(1, 1, 11, 100), 5, 1000, 0.5),
+        ("low", RequestRate(1, 1, 3, 11), 1, 10, 1),
+        ("low", RequestRate(1, 1, 3, 11), 1, 5, 0.5),
+        ("high", RequestRate(10, 10, 10, 100), 42, 1000, 42),
+        ("high", RequestRate(999, 10, 10, 100), 42, 1000, 42),
+        ("medium", RequestRate(10, 100, 10, 100), 42, 1000, 1000),
+        ("low", RequestRate(10, 1001, 10, 100), 42, 1000, 1000),
+    ),
+)
+def test_get_wait_time(
+    priority,
+    rates,
+    seconds_until_short_limit,
+    seconds_until_long_limit,
+    expected_wait_time,
+):
+    rule = SleepingRateLimitRule(priority=priority)
+    assert (
+        rule._get_wait_time(
+            rates, seconds_until_short_limit, seconds_until_long_limit
         )
-
-    def test_get_wait_time_medium_priority(self):
-        """Should return number of seconds to next short limit divided by number of remaining requests
-        for that period"""
-        rule = SleepingRateLimitRule(priority="medium", short_limit=11)
-        self.assertEqual(1, rule._get_wait_time(1, 1, 10, 1000))
-        self.assertEqual(0.5, rule._get_wait_time(1, 1, 5, 1000))
-
-    def test_get_wait_time_low_priority(self):
-        """Should return number of seconds to next long limit divided by number of remaining requests
-        for that period"""
-        rule = SleepingRateLimitRule(priority="low", long_limit=11)
-        self.assertEqual(1, rule._get_wait_time(1, 1, 1, 10))
-        self.assertEqual(0.5, rule._get_wait_time(1, 1, 1, 5))
-
-    def test_get_wait_time_limit_reached(self):
-        """Should wait until end of period when limit is reached, regardless priority"""
-        rule = SleepingRateLimitRule(short_limit=10, long_limit=100)
-        self.assertEqual(42, rule._get_wait_time(10, 10, 42, 1000))
-        self.assertEqual(42, rule._get_wait_time(1234, 10, 42, 1000))
-        self.assertEqual(21, rule._get_wait_time(10, 100, 42, 21))
-        self.assertEqual(21, rule._get_wait_time(10, 1234, 42, 21))
-
-    def test_invocation_unchanged_limits(self):
-        """Should not update limits if these don't change"""
-        self.test_response["X-RateLimit-Usage"] = "0, 0"
-        self.test_response["X-RateLimit-Limit"] = "10000, 1000000"
-        rule = SleepingRateLimitRule()
-        self.assertEqual(10000, rule.short_limit)
-        self.assertEqual(1000000, rule.long_limit)
-        rule(self.test_response)
-        self.assertEqual(10000, rule.short_limit)
-        self.assertEqual(1000000, rule.long_limit)
-
-    def test_invocation_changed_limits(self):
-        """Should update limits in case of changes, depending on limit enforcement"""
-        self.test_response["X-RateLimit-Usage"] = "0, 0"
-        self.test_response["X-RateLimit-Limit"] = "600, 30000"
-
-        # without limit enforcement (default)
-        rule = SleepingRateLimitRule()
-        rule(self.test_response)
-        self.assertEqual(600, rule.short_limit)
-        self.assertEqual(30000, rule.long_limit)
-
-        # with limit enforcement
-        rule = SleepingRateLimitRule(force_limits=True)
-        rule(self.test_response)
-        self.assertEqual(10000, rule.short_limit)
-        self.assertEqual(1000000, rule.long_limit)
+        == expected_wait_time
+    )