Coverage for manila/api/v2/limits.py: 94%
186 statements
« prev ^ index » next coverage.py v7.11.0, created at 2026-02-18 22:19 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2026-02-18 22:19 +0000
1# Copyright 2011 OpenStack LLC.
2# All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may
5# not use this file except in compliance with the License. You may obtain
6# a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations
14# under the License.
16"""
17Module dedicated functions/classes dealing with rate limiting requests.
18"""
20import collections
21import copy
22from http import client as http_client
23import math
24import re
25import time
27from oslo_serialization import jsonutils
28from oslo_utils import importutils
29import webob.dec
30import webob.exc
32from manila.api.openstack import wsgi
33from manila.api.views import limits as limits_views
34from manila.i18n import _
35from manila import quota
36from manila.wsgi import common as base_wsgi
38QUOTAS = quota.QUOTAS
41# Convenience constants for the limits dictionary passed to Limiter().
42PER_SECOND = 1
43PER_MINUTE = 60
44PER_HOUR = 60 * 60
45PER_DAY = 60 * 60 * 24
48class LimitsController(wsgi.Controller):
49 """Controller for accessing limits in the OpenStack API."""
51 def index(self, req):
52 """Return all global and rate limit information."""
53 context = req.environ['manila.context']
54 quotas = QUOTAS.get_project_quotas(context, context.project_id,
55 usages=True)
56 abs_limits = {'in_use': {}, 'limit': {}}
57 for k, v in quotas.items():
58 abs_limits['limit'][k] = v['limit']
59 abs_limits['in_use'][k] = v['in_use']
60 rate_limits = req.environ.get("manila.limits", [])
62 builder = self._get_view_builder(req)
63 return builder.build(req, rate_limits, abs_limits)
65 def _get_view_builder(self, req):
66 return limits_views.ViewBuilder()
69def create_resource():
70 return wsgi.Resource(LimitsController())
73class Limit(object):
74 """Stores information about a limit for HTTP requests."""
76 UNITS = {
77 1: "SECOND",
78 60: "MINUTE",
79 60 * 60: "HOUR",
80 60 * 60 * 24: "DAY",
81 }
83 UNIT_MAP = {v: k for k, v in UNITS.items()}
85 def __init__(self, verb, uri, regex, value, unit):
86 """Initialize a new `Limit`.
88 @param verb: HTTP verb (POST, PUT, etc.)
89 @param uri: Human-readable URI
90 @param regex: Regular expression format for this limit
91 @param value: Integer number of requests which can be made
92 @param unit: Unit of measure for the value parameter
93 """
94 self.verb = verb
95 self.uri = uri
96 self.regex = regex
97 self.value = int(value)
98 self.unit = unit
99 self.unit_string = self.display_unit().lower()
100 self.remaining = int(value)
102 if value <= 0: 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true
103 raise ValueError("Limit value must be > 0")
105 self.last_request = None
106 self.next_request = None
108 self.water_level = 0
109 self.capacity = self.unit
110 self.request_value = float(self.capacity) / float(self.value)
111 msg = (_("Only %(value)s %(verb)s request(s) can be "
112 "made to %(uri)s every %(unit_string)s.") %
113 {'value': self.value, 'verb': self.verb,
114 'uri': self.uri, 'unit_string': self.unit_string})
115 self.error_message = msg
117 def __call__(self, verb, url):
118 """Represents a call to this limit from a relevant request.
120 @param verb: string http verb (POST, GET, etc.)
121 @param url: string URL
122 """
123 if self.verb != verb or not re.match(self.regex, url):
124 return
126 now = self._get_time()
128 if self.last_request is None:
129 self.last_request = now
131 leak_value = now - self.last_request
133 self.water_level -= leak_value
134 self.water_level = max(self.water_level, 0)
135 self.water_level += self.request_value
137 difference = self.water_level - self.capacity
139 self.last_request = now
141 if difference > 0:
142 self.water_level -= self.request_value
143 self.next_request = now + difference
144 return difference
146 cap = self.capacity
147 water = self.water_level
148 val = self.value
150 self.remaining = math.floor(((cap - water) / cap) * val)
151 self.next_request = now
153 def _get_time(self):
154 """Retrieve the current time. Broken out for testability."""
155 return time.time()
157 def display_unit(self):
158 """Display the string name of the unit."""
159 return self.UNITS.get(self.unit, "UNKNOWN")
161 def display(self):
162 """Return a useful representation of this class."""
163 return {
164 "verb": self.verb,
165 "URI": self.uri,
166 "regex": self.regex,
167 "value": self.value,
168 "remaining": int(self.remaining),
169 "unit": self.display_unit(),
170 "resetTime": int(self.next_request or self._get_time()),
171 }
174# "Limit" format is a dictionary with the HTTP verb, human-readable URI,
175# a regular-expression to match, value and unit of measure (PER_DAY, etc.)
177DEFAULT_LIMITS = [
178 Limit("POST", "*", ".*", 10, PER_MINUTE),
179 Limit("POST", "*/servers", "^/servers", 50, PER_DAY),
180 Limit("PUT", "*", ".*", 10, PER_MINUTE),
181 Limit("GET", "*changes-since*", ".*changes-since.*", 3, PER_MINUTE),
182 Limit("DELETE", "*", ".*", 100, PER_MINUTE),
183]
186class RateLimitingMiddleware(base_wsgi.Middleware):
187 """Rate-limits requests passing through this middleware.
189 All limit information is stored in memory for this implementation.
190 """
192 def __init__(self, application, limits=None, limiter=None, **kwargs):
193 """Initialize new `RateLimitingMiddleware`.
195 `RateLimitingMiddleware` wraps the given WSGI application and
196 sets up the given limits.
198 @param application: WSGI application to wrap
199 @param limits: String describing limits
200 @param limiter: String identifying class for representing limits
202 Other parameters are passed to the constructor for the limiter.
203 """
204 base_wsgi.Middleware.__init__(self, application)
206 # Select the limiter class
207 if limiter is None: 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true
208 limiter = Limiter
209 else:
210 limiter = importutils.import_class(limiter)
212 # Parse the limits, if any are provided
213 if limits is not None: 213 ↛ 216line 213 didn't jump to line 216 because the condition on line 213 was always true
214 limits = limiter.parse_limits(limits)
216 self._limiter = limiter(limits or DEFAULT_LIMITS, **kwargs)
218 @webob.dec.wsgify(RequestClass=wsgi.Request)
219 def __call__(self, req):
220 """Represents a single call through this middleware.
222 We should record the request if we have a limit relevant to
223 it. If no limit is relevant to the request, ignore it.
225 If the request should be rate limited, return a fault telling
226 the user they are over the limit and need to retry later.
227 """
228 verb = req.method
229 url = req.url
230 context = req.environ.get("manila.context")
232 if context: 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true
233 username = context.user_id
234 else:
235 username = None
237 delay, error = self._limiter.check_for_delay(verb, url, username)
239 if delay:
240 msg = _("This request was rate-limited.")
241 retry = time.time() + delay
242 return wsgi.OverLimitFault(msg, error, retry)
244 req.environ["manila.limits"] = self._limiter.get_limits(username)
246 return self.application
249class Limiter(object):
250 """Rate-limit checking class which handles limits in memory."""
252 def __init__(self, limits, **kwargs):
253 """Initialize the new `Limiter`.
255 @param limits: List of `Limit` objects
256 """
257 self.limits = copy.deepcopy(limits)
258 self.levels = collections.defaultdict(lambda: copy.deepcopy(limits))
260 # Pick up any per-user limit information
261 for key, value in kwargs.items():
262 if key.startswith('user:'): 262 ↛ 261line 262 didn't jump to line 261 because the condition on line 262 was always true
263 username = key[5:]
264 self.levels[username] = self.parse_limits(value)
266 def get_limits(self, username=None):
267 """Return the limits for a given user."""
268 return [limit.display() for limit in self.levels[username]]
270 def check_for_delay(self, verb, url, username=None):
271 """Check the given verb/user/user triplet for limit.
273 @return: Tuple of delay (in seconds) and error message (or None, None)
274 """
275 delays = []
277 for limit in self.levels[username]:
278 delay = limit(verb, url)
279 if delay:
280 delays.append((delay, limit.error_message))
282 if delays:
283 delays.sort()
284 return delays[0]
286 return None, None
288 # Note: This method gets called before the class is instantiated,
289 # so this must be either a static method or a class method. It is
290 # used to develop a list of limits to feed to the constructor. We
291 # put this in the class so that subclasses can override the
292 # default limit parsing.
293 @staticmethod
294 def parse_limits(limits):
295 """Convert a string into a list of Limit instances.
297 This implementation expects a semicolon-separated sequence of
298 parenthesized groups, where each group contains a
299 comma-separated sequence consisting of HTTP method,
300 user-readable URI, a URI reg-exp, an integer number of
301 requests which can be made, and a unit of measure. Valid
302 values for the latter are "SECOND", "MINUTE", "HOUR", and
303 "DAY".
305 @return: List of Limit instances.
306 """
308 # Handle empty limit strings
309 limits = limits.strip()
310 if not limits:
311 return []
313 # Split up the limits by semicolon
314 result = []
315 for group in limits.split(';'):
316 group = group.strip()
317 if group[:1] != '(' or group[-1:] != ')':
318 raise ValueError("Limit rules must be surrounded by "
319 "parentheses")
320 group = group[1:-1]
322 # Extract the Limit arguments
323 args = [a.strip() for a in group.split(',')]
324 if len(args) != 5:
325 raise ValueError("Limit rules must contain the following "
326 "arguments: verb, uri, regex, value, unit")
328 # Pull out the arguments
329 verb, uri, regex, value, unit = args
331 # Upper-case the verb
332 verb = verb.upper()
334 # Convert value--raises ValueError if it's not integer
335 value = int(value)
337 # Convert unit
338 unit = unit.upper()
339 if unit not in Limit.UNIT_MAP:
340 raise ValueError("Invalid units specified")
341 unit = Limit.UNIT_MAP[unit]
343 # Build a limit
344 result.append(Limit(verb, uri, regex, value, unit))
346 return result
349class WsgiLimiter(object):
350 """Rate-limit checking from a WSGI application.
352 Uses an in-memory `Limiter`.
353 To use, POST ``/<username>`` with JSON data such as::
355 {
356 "verb" : GET,
357 "path" : "/servers"
358 }
360 and receive a 204 No Content, or a 403 Forbidden with an X-Wait-Seconds
361 header containing the number of seconds to wait before the action would
362 succeed.
363 """
365 def __init__(self, limits=None):
366 """Initialize the new `WsgiLimiter`.
368 @param limits: List of `Limit` objects
369 """
370 self._limiter = Limiter(limits or DEFAULT_LIMITS)
372 @webob.dec.wsgify(RequestClass=wsgi.Request)
373 def __call__(self, request):
374 """Handles a call to this application.
376 Returns 204 if the request is acceptable to the limiter, else
377 a 403 is returned with a relevant header indicating when the
378 request *will* succeed.
379 """
380 if request.method != "POST":
381 raise webob.exc.HTTPMethodNotAllowed()
383 try:
384 info = dict(jsonutils.loads(request.body))
385 except ValueError:
386 raise webob.exc.HTTPBadRequest()
388 username = request.path_info_pop()
389 verb = info.get("verb")
390 path = info.get("path")
392 delay, error = self._limiter.check_for_delay(verb, path, username)
394 if delay:
395 headers = {"X-Wait-Seconds": "%.2f" % delay}
396 return webob.exc.HTTPForbidden(headers=headers, explanation=error)
397 else:
398 return webob.exc.HTTPNoContent()
401class WsgiLimiterProxy(object):
402 """Rate-limit requests based on answers from a remote source."""
404 def __init__(self, limiter_address):
405 """Initialize the new `WsgiLimiterProxy`.
407 @param limiter_address: IP/port combination of where to request limit
408 """
409 self.limiter_address = limiter_address
411 def check_for_delay(self, verb, path, username=None):
412 body = jsonutils.dumps({"verb": verb, "path": path})
413 headers = {"Content-Type": "application/json"}
415 conn = http_client.HTTPConnection(self.limiter_address)
417 if username: 417 ↛ 418line 417 didn't jump to line 418 because the condition on line 417 was never true
418 conn.request("POST", "/%s" % (username), body, headers)
419 else:
420 conn.request("POST", "/", body, headers)
422 resp = conn.getresponse()
424 if resp.status >= 200 and resp.status < 300:
425 # there's nothing to rate-limit
426 return None, None
428 return resp.getheader("X-Wait-Seconds"), resp.read() or None
430 # Note: This method gets called before the class is instantiated,
431 # so this must be either a static method or a class method. It is
432 # used to develop a list of limits to feed to the constructor.
433 # This implementation returns an empty list, since all limit
434 # decisions are made by a remote server.
435 @staticmethod
436 def parse_limits(limits):
437 """Ignore a limits string.
439 This simply doesn't apply for the limit proxy.
441 @return: Empty list.
442 """
444 return []