Skip to content

Commit 4403d3c

Browse files
committed
enforce_rl
Signed-off-by: Finbarrs Oketunji <f@finbarrs.eu>
1 parent 1c68ae3 commit 4403d3c

File tree

7 files changed

+148
-10
lines changed

7 files changed

+148
-10
lines changed

CHANGELOG.md

+11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## 1.0.2 - 2023-09-25
4+
* Added:
5+
+ ratelimit.py
6+
7+
* Modified:
8+
+ __init__.py
9+
+ motdata.py
10+
+ test.py
11+
+ setup.py
12+
+ VERSION
13+
314
## 1.0.1 - 2023-09-21
415
* Updated: test.py, README.md, and LONG_DESCRIPTION.rst
516

VERSION

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.0.1
1+
1.0.2

motapi/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@
1414
"MOTHistoryAPI"
1515
]
1616

17-
__version__ = "1.0.1"
17+
__version__ = "1.0.2"

motapi/motdata.py

+25-6
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""MOT Data API Client for interacting with the MOT history API."""
22

33
from typing import Dict, Optional
4+
from .ratelimit import limits, sleep_and_retry, RateLimitExceeded
45
import requests
5-
6+
import time
67

78
class MOTHistoryAPI:
89
"""Base class for interacting with the MOT history API."""
@@ -11,6 +12,11 @@ class MOTHistoryAPI:
1112
TOKEN_URL = "https://login.microsoftonline.com/a455b827-244f-4c97-b5b4-ce5d13b4d00c/oauth2/v2.0/token"
1213
SCOPE_URL = "https://tapi.dvsa.gov.uk/.default"
1314

15+
# Rate Limiting
16+
QUOTA_LIMIT = 500000 # Maximum number of requests per day
17+
BURST_LIMIT = 10 # Maximum number of requests in a short burst
18+
RPS_LIMIT = 15 # Average number of requests per second
19+
1420
def __init__(self, client_id: str, client_secret: str, api_key: str):
1521
"""
1622
Initialise the MOT History API client.
@@ -24,6 +30,8 @@ def __init__(self, client_id: str, client_secret: str, api_key: str):
2430
self.client_secret = client_secret
2531
self.api_key = api_key
2632
self.access_token = self._get_access_token()
33+
self.request_count = 0
34+
self.last_request_time = time.time()
2735

2836
def _get_access_token(self) -> str:
2937
"""
@@ -57,9 +65,12 @@ def _get_headers(self) -> Dict[str, str]:
5765
"X-API-Key": self.api_key,
5866
}
5967

68+
@sleep_and_retry
69+
@limits(calls=BURST_LIMIT, period=1)
70+
@limits(calls=QUOTA_LIMIT, period=86400)
6071
def _make_request(self, endpoint: str, params: Optional[Dict] = None) -> Dict:
6172
"""
62-
Make a GET request to the API.
73+
Make a GET request to the API with rate limiting.
6374
6475
Args:
6576
endpoint (str): The API endpoint.
@@ -70,12 +81,23 @@ def _make_request(self, endpoint: str, params: Optional[Dict] = None) -> Dict:
7081
7182
Raises:
7283
requests.exceptions.HTTPError: If the API request fails.
84+
RateLimitExceeded: If the rate limit is exceeded.
7385
"""
86+
# RPS Limiting
87+
current_time = time.time()
88+
time_since_last_request = current_time - self.last_request_time
89+
if time_since_last_request < 1 / self.RPS_LIMIT:
90+
sleep_time = (1 / self.RPS_LIMIT) - time_since_last_request
91+
raise RateLimitExceeded("RPS limit exceeded", sleep_time)
92+
7493
url = f"{self.BASE_URL}/{endpoint}"
7594
response = requests.get(url, headers=self._get_headers(), params=params)
7695
response.raise_for_status()
77-
return response.json()
7896

97+
self.request_count += 1
98+
self.last_request_time = time.time()
99+
100+
return response.json()
79101

80102
class VehicleData(MOTHistoryAPI):
81103
"""Class for retrieving vehicle data."""
@@ -104,7 +126,6 @@ def get_by_vin(self, vin: str) -> Dict:
104126
"""
105127
return self._make_request(f"vin/{vin}")
106128

107-
108129
class BulkData(MOTHistoryAPI):
109130
"""Class for retrieving bulk MOT history data."""
110131

@@ -117,7 +138,6 @@ def get_bulk_download(self) -> Dict:
117138
"""
118139
return self._make_request("bulk-download")
119140

120-
121141
class CredentialsManager(MOTHistoryAPI):
122142
"""Class for managing API credentials."""
123143

@@ -143,7 +163,6 @@ def renew_client_secret(self, email: str) -> str:
143163
response.raise_for_status()
144164
return response.json()["clientSecret"]
145165

146-
147166
class MOTDataClient:
148167
"""Main client class for interacting with the MOT history API."""
149168

motapi/ratelimit.py

+87
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""The module provides rate-limiting decorators for functions and methods.
2+
3+
It allows limiting the number of times a function can be called within a specified period.
4+
"""
5+
6+
import time
7+
from functools import wraps
8+
9+
10+
class RateLimitExceeded(Exception):
11+
"""Exception raised when the rate limit is exceeded."""
12+
13+
def __init__(self, message: str, sleep_time: float):
14+
"""
15+
Initialize the RateLimitExceeded exception.
16+
17+
Args:
18+
message (str): The error message.
19+
sleep_time (float): The time to sleep before retrying.
20+
"""
21+
super().__init__(message)
22+
self.sleep_time = sleep_time
23+
24+
25+
def sleep_and_retry(func):
26+
"""
27+
A decorator that catches RateLimitExceeded exceptions and retries the function after sleeping.
28+
29+
Args:
30+
func (callable): The function to be decorated.
31+
32+
Returns:
33+
callable: The wrapped function that implements the sleep and retry behaviour.
34+
"""
35+
@wraps(func)
36+
def wrapper(*args, **kwargs):
37+
while True:
38+
try:
39+
return func(*args, **kwargs)
40+
except RateLimitExceeded as e:
41+
time.sleep(e.sleep_time)
42+
return wrapper
43+
44+
45+
def limits(calls=15, period=900, raise_on_limit=True):
46+
"""
47+
A decorator factory that returns a decorator to rate limit function calls.
48+
49+
Args:
50+
calls (int): The maximum number of calls allowed within the specified period.
51+
period (int): The time period in seconds for which the calls are counted.
52+
raise_on_limit (bool): If True, raises RateLimitExceeded when limit is reached.
53+
If False, blocks until the function can be called again.
54+
55+
Returns:
56+
callable: A decorator that implements the rate limiting behaviour.
57+
"""
58+
def decorator(func):
59+
# Initialize the state for tracking function calls
60+
func.__last_reset = time.monotonic()
61+
func.__num_calls = 0
62+
63+
@wraps(func)
64+
def wrapper(*args, **kwargs):
65+
# Check if we need to reset the call count
66+
now = time.monotonic()
67+
time_since_reset = now - func.__last_reset
68+
if time_since_reset > period:
69+
func.__num_calls = 0
70+
func.__last_reset = now
71+
72+
# Check if we've exceeded the rate limit
73+
if func.__num_calls >= calls:
74+
sleep_time = period - time_since_reset
75+
if raise_on_limit:
76+
raise RateLimitExceeded("Rate limit exceeded", sleep_time)
77+
else:
78+
time.sleep(sleep_time)
79+
return wrapper(*args, **kwargs)
80+
81+
# Call the function and increment the call count
82+
func.__num_calls += 1
83+
return func(*args, **kwargs)
84+
85+
return wrapper
86+
87+
return decorator

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from distutils.core import Extension
66

77
NAME = "mot-history-api-py-sdk"
8-
VERSION = "1.0.1"
8+
VERSION = "1.0.2"
99
REQUIRES = ["requests"]
1010

1111
# read the contents of your README file

test.py

+22-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""Test script for MOT Data API Client.
22
33
It tests all the main functionalities of the MOT Data API Client,
4-
including handling various VIN lengths.
4+
including handling various VIN lengths and rate limiting scenarios.
55
"""
66

77
import os
88
import sys
9+
import time
910
from pprint import pprint
1011
from typing import Optional
1112
from unittest.mock import Mock
@@ -14,8 +15,10 @@
1415
# but falls back to a mock if it is not present.
1516
try:
1617
from motapi import MOTDataClient
18+
from motapi.ratelimit import RateLimitExceeded
1719
except ImportError:
1820
MOTDataClient = Mock()
21+
RateLimitExceeded = Exception
1922

2023
# Retrieve credentials from environment variables
2124
CLIENT_ID: Optional[str] = os.environ.get("MOT_CLIENT_ID")
@@ -108,6 +111,23 @@ def test_edge_case_vins():
108111
except Exception as e:
109112
print(f"Error retrieving data for VIN {vin}: {e}")
110113

114+
def test_rate_limiting():
115+
"""Test rate limiting functionality."""
116+
print("\nTesting rate limiting:")
117+
start_time = time.time()
118+
request_count = 0
119+
try:
120+
while time.time() - start_time < 5: # Run for 5 seconds
121+
client.get_vehicle_data("ML58FOU")
122+
request_count += 1
123+
except RateLimitExceeded as e:
124+
print(f"Rate limit exceeded after {request_count} requests in {time.time() - start_time:.2f} seconds")
125+
print(f"Rate limit exception details: {e}")
126+
except Exception as e:
127+
print(f"Unexpected error during rate limit testing: {e}")
128+
else:
129+
print(f"Made {request_count} requests in 5 seconds without hitting rate limit")
130+
111131
if __name__ == "__main__":
112132
print("Starting MOT Data API Client tests...")
113133

@@ -118,5 +138,6 @@ def test_edge_case_vins():
118138
test_renew_client_secret()
119139
test_invalid_vehicle_identifier()
120140
test_edge_case_vins()
141+
test_rate_limiting()
121142

122143
print("\nAll tests completed.")

0 commit comments

Comments
 (0)