pytest-clerk 4.0.0__py3-none-any.whl → 4.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pytest_clerk/clerk.py CHANGED
@@ -1,8 +1,12 @@
1
+ import logging
2
+ import re
1
3
  from contextlib import suppress
4
+ from time import sleep
2
5
 
3
6
  import httpx
4
7
  import pytest
5
8
  from decouple import UndefinedValueError, config
9
+ from limits import RateLimitItemPerSecond, storage, strategies
6
10
  from tenacity import retry, retry_if_exception, wait_random_exponential
7
11
 
8
12
  retryable_status_codes = (
@@ -13,6 +17,165 @@ retryable_status_codes = (
13
17
  httpx.codes.GATEWAY_TIMEOUT,
14
18
  )
15
19
 
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class ClerkRateLimiter:
24
+ """This class manages all of the rate limits for the various API endpoints of Clerk
25
+ for both the front end and back end APIs.
26
+ """
27
+
28
+ def __init__(self):
29
+ """Initialize all of the rate limits."""
30
+ # Use an instance of an object to specify the "no limits" default. This is so
31
+ # that we don't pass any `is None` checks when the default is specified as no
32
+ # limits.
33
+ self.no_limit = object()
34
+ self.backend_rate_limits = {
35
+ # 20 requests per 10 seconds.
36
+ "POST": {r"/v1/users": RateLimitItemPerSecond(amount=20, multiples=10)},
37
+ # No rate limits.
38
+ "GET": {r"/v1/jwks": self.no_limit},
39
+ # 100 requests per 10 seconds.
40
+ "DEFAULT": RateLimitItemPerSecond(amount=100, multiples=10),
41
+ }
42
+ self.frontend_rate_limits = {
43
+ "POST": {
44
+ # 3 requests per 10 seconds.
45
+ r"/v1/client/sign_ins/(?P<sign_in_id>.*?)/attempt_first_factor": RateLimitItemPerSecond(
46
+ amount=3, multiples=10
47
+ ),
48
+ # 3 requests per 10 seconds.
49
+ r"/v1/client/sign_ins/(?P<sign_in_id>.*?)/attempt_second_factor": RateLimitItemPerSecond(
50
+ amount=3, multiples=10
51
+ ),
52
+ # 3 requests per 10 seconds.
53
+ r"/v1/client/sign_ups/(?P<sign_up_id>.*?)/attempt_verification": RateLimitItemPerSecond(
54
+ amount=3, multiples=10
55
+ ),
56
+ # 5 requests per 10 seconds.
57
+ r"/v1/client/sign_ins": RateLimitItemPerSecond(amount=5, multiples=10),
58
+ # 5 requests per 10 seconds.
59
+ r"/v1/client/sign_ups": RateLimitItemPerSecond(amount=5, multiples=10),
60
+ }
61
+ }
62
+ self.storage = storage.MemoryStorage()
63
+ self.strategy = strategies.FixedWindowRateLimiter(self.storage)
64
+
65
+ def get_rate_limit_item(self, request, rate_limits):
66
+ """Return the found rate limit item and namespace to use when checking the rate
67
+ limit for this request.
68
+
69
+ The returned namespace should be used as the unique identifier in order to group
70
+ the various rate limit checks.
71
+
72
+ If there is no rate limit specified, the rate limit item will be `None`. If
73
+ there is a rate limit specified, but it is unlimited, the rate limit item will
74
+ be `self.no_limit`.
75
+ """
76
+ path = request.url.path
77
+ method = request.method
78
+ host = request.url.host
79
+
80
+ logger.debug(
81
+ "Searching for rate limit for method %s, host %s, and path %s.",
82
+ method,
83
+ host,
84
+ path,
85
+ )
86
+
87
+ rate_limit_item = None
88
+ namespace = None
89
+
90
+ # Check all the configured rate limits for the method.
91
+ method_limits = rate_limits.get(method, {})
92
+ for path_regex, rate_limit in method_limits.items():
93
+ logger.debug(
94
+ "Checking if regex %s and rate limit %s matches for method %s, host %s,"
95
+ " and path %s.",
96
+ path_regex,
97
+ rate_limit,
98
+ method,
99
+ host,
100
+ path,
101
+ )
102
+
103
+ if re.match(path_regex, path):
104
+ rate_limit_item = rate_limit
105
+ namespace = f"{host}:{path}:{method}"
106
+ break
107
+
108
+ # If we didn't get a limit for the method + path, try the default for that
109
+ # method.
110
+ if rate_limit_item is None:
111
+ logger.debug(
112
+ "No rate limit found for method %s, host %s, and path %s. Trying"
113
+ " default rate limit for method %s and host %s.",
114
+ method,
115
+ host,
116
+ path,
117
+ method,
118
+ host,
119
+ )
120
+ rate_limit_item = method_limits.get("DEFAULT")
121
+ namespace = f"{host}:{method}"
122
+
123
+ # If there was no default limit for the method, try the overall default.
124
+ if rate_limit_item is None:
125
+ logger.debug(
126
+ "No rate limit found for method %s and host %s. Trying global default.",
127
+ method,
128
+ host,
129
+ )
130
+ rate_limit_item = rate_limits.get("DEFAULT")
131
+ namespace = host
132
+
133
+ logger.debug(
134
+ "Found rate limit %s with namespace %s for method %s, host %s, and path"
135
+ " %s.",
136
+ rate_limit_item,
137
+ namespace,
138
+ method,
139
+ host,
140
+ path,
141
+ )
142
+ return rate_limit_item, namespace
143
+
144
+ def rate_limit_hook(self, request):
145
+ """Check the requeste URL and hit the appropriate rate limit."""
146
+ # Get the rate limit for the request.
147
+ if request.url.host == "api.clerk.com":
148
+ logger.debug("Checking back end rate limits for request %s.", request)
149
+ rate_limit_item, namespace = self.get_rate_limit_item(
150
+ request=request, rate_limits=self.backend_rate_limits
151
+ )
152
+ else:
153
+ logger.debug("Checking front end rate limits for request %s.", request)
154
+ rate_limit_item, namespace = self.get_rate_limit_item(
155
+ request=request, rate_limits=self.frontend_rate_limits
156
+ )
157
+
158
+ # If the rate limit was specified as unlimited, do nothing.
159
+ if rate_limit_item is self.no_limit:
160
+ logger.debug("The rate limit is unlimited for request %s.", request)
161
+ return
162
+
163
+ # If there is no rate limit, do nothing.
164
+ if rate_limit_item is None:
165
+ logger.debug("There was no rate limit found for request %s.", request)
166
+ return
167
+
168
+ logger.debug("The rate limit is %s for request %s.", rate_limit_item, request)
169
+
170
+ # Check if we hit the rate limit.
171
+ while not self.strategy.hit(rate_limit_item, namespace):
172
+ logger.info(
173
+ "Hit rate limit for namespace %s while making request %s.",
174
+ namespace,
175
+ request,
176
+ )
177
+ sleep(1)
178
+
16
179
 
17
180
  def retry_predicate(exception):
18
181
  """Return whether to retry. This depends on getting an exception with a response
@@ -24,6 +187,12 @@ def retry_predicate(exception):
24
187
  )
25
188
 
26
189
 
190
+ @pytest.fixture(scope="session")
191
+ def clerk_rate_limiter():
192
+ """Return an instance of the Clerk rate limiter for use with HTTPX request hooks."""
193
+ return ClerkRateLimiter()
194
+
195
+
27
196
  @pytest.fixture(scope="session")
28
197
  def clerk_secret_key(request):
29
198
  """Retrieve the clerk secret key to use for the test.
@@ -54,7 +223,7 @@ def clerk_secret_key(request):
54
223
 
55
224
 
56
225
  @pytest.fixture(scope="session")
57
- def clerk_backend_httpx_client(clerk_secret_key):
226
+ def clerk_backend_httpx_client(clerk_secret_key, clerk_rate_limiter):
58
227
  """A fixture that creates a HTTPX Client instance with the required backend Clerk
59
228
  Authorization headers set and the correct Clerk backend API base URL.
60
229
 
@@ -64,6 +233,7 @@ def clerk_backend_httpx_client(clerk_secret_key):
64
233
  client = httpx.Client(
65
234
  headers={"Authorization": f"Bearer {clerk_secret_key}"},
66
235
  base_url="https://api.clerk.com/v1",
236
+ event_hooks={"request": [clerk_rate_limiter.rate_limit_hook]},
67
237
  )
68
238
 
69
239
  yield client
@@ -89,7 +259,7 @@ def clerk_frontend_api_url():
89
259
 
90
260
 
91
261
  @pytest.fixture(scope="session")
92
- def clerk_frontend_httpx_client(clerk_frontend_api_url):
262
+ def clerk_frontend_httpx_client(clerk_frontend_api_url, clerk_rate_limiter):
93
263
  """This fixture returns a function that creates an HTTPX Client instance with the
94
264
  required frontend Clerk Authorization parameters set and the correct Clerk frontend
95
265
  API base URL.
@@ -106,6 +276,7 @@ def clerk_frontend_httpx_client(clerk_frontend_api_url):
106
276
  client = httpx.Client(
107
277
  params={"__dev_session": result.json()["token"]},
108
278
  base_url=f"{clerk_frontend_api_url}/v1",
279
+ event_hooks={"request": [clerk_rate_limiter.rate_limit_hook]},
109
280
  )
110
281
 
111
282
  yield client
@@ -1,25 +1,19 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: pytest-clerk
3
- Version: 4.0.0
3
+ Version: 4.0.2
4
4
  Summary: A set of pytest fixtures to help with integration testing with Clerk.
5
- Home-page: https://gitlab.com/munipal-oss/pytest-clerk
6
5
  License: MIT
7
6
  Author: Ryan Causey
8
7
  Author-email: ryan.causey@munipal.io
9
8
  Requires-Python: >=3.10,<4.0
10
9
  Classifier: Framework :: Pytest
11
- Classifier: License :: OSI Approved :: MIT License
12
- Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.10
14
- Classifier: Programming Language :: Python :: 3.11
15
- Classifier: Programming Language :: Python :: 3.12
16
10
  Provides-Extra: aws
17
- Requires-Dist: httpx (>=0.27.0,<0.28.0)
11
+ Requires-Dist: httpx (>=0.28.0,<1.0.0)
12
+ Requires-Dist: limits (>=4.0.1,<5.0.0)
18
13
  Requires-Dist: pytest (>=8.0.0,<9.0.0)
19
- Requires-Dist: pytest-aws-fixtures (>=3.0.0,<4.0.0) ; extra == "aws"
14
+ Requires-Dist: pytest-aws-fixtures (>=3.0.0,<4.0) ; extra == "aws"
20
15
  Requires-Dist: python-decouple (>=3.0,<4.0)
21
16
  Requires-Dist: tenacity (>=9.0.0,<10.0.0)
22
- Project-URL: Repository, https://gitlab.com/munipal-oss/pytest-clerk
23
17
  Description-Content-Type: text/markdown
24
18
 
25
19
  # Pytest Clerk
@@ -0,0 +1,7 @@
1
+ pytest_clerk/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pytest_clerk/clerk.py,sha256=fjf4Io81i2grKuFE8AwzHd7X9vhjpet7rXTGVsCKhxQ,25203
3
+ pytest_clerk-4.0.2.dist-info/LICENSE,sha256=QLSYHsNt-ZLbbVtDs7h8o8v-V0SlK2BuGe7LmoPOasU,1064
4
+ pytest_clerk-4.0.2.dist-info/METADATA,sha256=I3IhRKapB-S3fpW44d60PewoMAidviBn9GNfIIT9yHs,1291
5
+ pytest_clerk-4.0.2.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
6
+ pytest_clerk-4.0.2.dist-info/entry_points.txt,sha256=ps5MgIGlDiWP4lufTAzoQhfwL-GA7rJquc1bqnVR-oA,44
7
+ pytest_clerk-4.0.2.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.9.0
2
+ Generator: poetry-core 2.0.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,7 +0,0 @@
1
- pytest_clerk/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- pytest_clerk/clerk.py,sha256=TOTk9lNy-AL6bi4r0DFIo4-dkH0GJtzkY_OSd0Ly2KQ,18599
3
- pytest_clerk-4.0.0.dist-info/LICENSE,sha256=QLSYHsNt-ZLbbVtDs7h8o8v-V0SlK2BuGe7LmoPOasU,1064
4
- pytest_clerk-4.0.0.dist-info/METADATA,sha256=XCiYdZ8Rpj4dx1Ziuv4B6IB6U2kT6SirgNan-0QtOhs,1631
5
- pytest_clerk-4.0.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
6
- pytest_clerk-4.0.0.dist-info/entry_points.txt,sha256=ps5MgIGlDiWP4lufTAzoQhfwL-GA7rJquc1bqnVR-oA,44
7
- pytest_clerk-4.0.0.dist-info/RECORD,,