@@ -140,10 +140,13 @@ def test_http_request_matching(mocker):
140140 users_policy .matches .side_effect = HttpRequestMatcher (
141141 url = "http://domain/api/users" , method = "GET"
142142 )
143+ users_policy .get_weight .return_value = 1
143144 groups_policy .matches .side_effect = HttpRequestMatcher (
144145 url = "http://domain/api/groups" , method = "POST"
145146 )
147+ groups_policy .get_weight .return_value = 1
146148 root_policy .matches .side_effect = HttpRequestMatcher (method = "GET" )
149+ root_policy .get_weight .return_value = 1
147150 api_budget = APIBudget (
148151 policies = [
149152 users_policy ,
@@ -360,6 +363,120 @@ def test_with_cache(self, mocker, requests_mock):
360363 assert MovingWindowCallRatePolicy .try_acquire .call_count == 1
361364
362365
366+ class TestWeightBasedRateLimiting :
367+ """Tests for weight-based rate limiting where different endpoints consume different amounts from a shared budget."""
368+
369+ def test_matcher_weight_default_none (self ):
370+ """HttpRequestRegexMatcher weight defaults to None when not specified."""
371+ matcher = HttpRequestRegexMatcher (url_path_pattern = r"/api/test" )
372+ assert matcher .weight is None
373+
374+ def test_matcher_weight_is_stored (self ):
375+ """HttpRequestRegexMatcher stores the weight value when provided."""
376+ matcher = HttpRequestRegexMatcher (url_path_pattern = r"/api/test" , weight = 60 )
377+ assert matcher .weight == 60
378+
379+ def test_matcher_rejects_zero_weight (self ):
380+ """HttpRequestRegexMatcher raises ValueError for weight=0."""
381+ with pytest .raises (ValueError , match = "weight must be >= 1" ):
382+ HttpRequestRegexMatcher (url_path_pattern = r"/api/test" , weight = 0 )
383+
384+ def test_matcher_rejects_negative_weight (self ):
385+ """HttpRequestRegexMatcher raises ValueError for negative weight."""
386+ with pytest .raises (ValueError , match = "weight must be >= 1" ):
387+ HttpRequestRegexMatcher (url_path_pattern = r"/api/test" , weight = - 5 )
388+
389+ def test_policy_get_weight_returns_matcher_weight (self ):
390+ """BaseCallRatePolicy.get_weight returns weight from the matching matcher."""
391+ policy = MovingWindowCallRatePolicy (
392+ matchers = [HttpRequestRegexMatcher (url_path_pattern = r"/api/expensive" , weight = 120 )],
393+ rates = [Rate (1000 , timedelta (hours = 1 ))],
394+ )
395+ req = Request ("GET" , "https://example.com/api/expensive" )
396+ assert policy .get_weight (req ) == 120
397+
398+ def test_policy_get_weight_defaults_to_1 (self ):
399+ """BaseCallRatePolicy.get_weight returns 1 when no matcher has a weight set."""
400+ policy = MovingWindowCallRatePolicy (
401+ matchers = [HttpRequestRegexMatcher (url_path_pattern = r"/api/default" )],
402+ rates = [Rate (1000 , timedelta (hours = 1 ))],
403+ )
404+ req = Request ("GET" , "https://example.com/api/default" )
405+ assert policy .get_weight (req ) == 1
406+
407+ def test_policy_get_weight_no_matching_matcher (self ):
408+ """BaseCallRatePolicy.get_weight returns 1 when no matcher matches the request."""
409+ policy = MovingWindowCallRatePolicy (
410+ matchers = [HttpRequestRegexMatcher (url_path_pattern = r"/api/other" , weight = 50 )],
411+ rates = [Rate (1000 , timedelta (hours = 1 ))],
412+ )
413+ req = Request ("GET" , "https://example.com/api/unmatched" )
414+ assert policy .get_weight (req ) == 1
415+
416+ def test_api_budget_uses_weight (self ):
417+ """APIBudget._do_acquire passes the matcher's weight to try_acquire."""
418+ policy = MovingWindowCallRatePolicy (
419+ matchers = [HttpRequestRegexMatcher (url_path_pattern = r"/api/heavy" , weight = 10 )],
420+ rates = [Rate (100 , timedelta (hours = 1 ))],
421+ )
422+ budget = APIBudget (policies = [policy ])
423+
424+ # Make requests — each weighs 10 from the budget of 100
425+ for i in range (10 ):
426+ budget .acquire_call (Request ("GET" , "https://example.com/api/heavy" ), block = False )
427+
428+ # The 11th request should exceed the budget (10 * 10 = 100, one more = 110 > 100)
429+ with pytest .raises (CallRateLimitHit ):
430+ budget .acquire_call (Request ("GET" , "https://example.com/api/heavy" ), block = False )
431+
432+ def test_weight_1_backward_compatible (self ):
433+ """When weight is not set, behavior is identical to the old hardcoded weight=1."""
434+ policy = MovingWindowCallRatePolicy (
435+ matchers = [HttpRequestRegexMatcher (url_path_pattern = r"/api/normal" )],
436+ rates = [Rate (5 , timedelta (hours = 1 ))],
437+ )
438+ budget = APIBudget (policies = [policy ])
439+
440+ for i in range (5 ):
441+ budget .acquire_call (Request ("GET" , "https://example.com/api/normal" ), block = False )
442+
443+ with pytest .raises (CallRateLimitHit ):
444+ budget .acquire_call (Request ("GET" , "https://example.com/api/normal" ), block = False )
445+
446+ def test_shared_budget_different_weights (self ):
447+ """Multiple matchers with different weights sharing one policy correctly consume the shared budget."""
448+ # Shared policy matches both endpoints via regex
449+ policy = MovingWindowCallRatePolicy (
450+ matchers = [
451+ HttpRequestRegexMatcher (url_path_pattern = r"/api/cheap" , weight = 1 ),
452+ HttpRequestRegexMatcher (url_path_pattern = r"/api/expensive" , weight = 10 ),
453+ ],
454+ rates = [Rate (20 , timedelta (hours = 1 ))],
455+ )
456+ budget = APIBudget (policies = [policy ])
457+
458+ # Make 1 expensive request (weight 10) and 10 cheap requests (weight 1 each) = total 20
459+ budget .acquire_call (Request ("GET" , "https://example.com/api/expensive" ), block = False )
460+ for i in range (10 ):
461+ budget .acquire_call (Request ("GET" , "https://example.com/api/cheap" ), block = False )
462+
463+ # Budget is now at 20/20 — any further request should fail
464+ with pytest .raises (CallRateLimitHit ):
465+ budget .acquire_call (Request ("GET" , "https://example.com/api/cheap" ), block = False )
466+
467+ def test_moving_window_rejects_weight_exceeding_limit (self ):
468+ """MovingWindowCallRatePolicy raises ValueError when weight exceeds the lowest configured rate limit."""
469+ policy = MovingWindowCallRatePolicy (
470+ matchers = [HttpRequestRegexMatcher (url_path_pattern = r"/api/heavy" , weight = 50 )],
471+ rates = [Rate (10 , timedelta (hours = 1 )), Rate (100 , timedelta (days = 1 ))],
472+ )
473+ req = Request ("GET" , "https://example.com/api/heavy" )
474+ with pytest .raises (
475+ ValueError , match = "Weight can not exceed the lowest configured rate limit"
476+ ):
477+ policy .try_acquire (req , weight = 50 )
478+
479+
363480class TestHttpRequestRegexMatcher :
364481 """
365482 Tests for the new regex-based logic:
0 commit comments