Remove middleware ratelimits from v3 api
The ratelimit middleware isn't really useful. That is pointed out by https://review.openstack.org/#/c/34821/. And it didn't get any object from mail-list: http://lists.openstack.org/pipermail/openstack-dev/2013-November/020291.html This patch remove ratelimit middleware and limits extension. The config option 'api_rate_limit' is only valid in v2 api. The pipeline factory for v3 api won't check that option anymore. For compatibility, if user is still using old 'api-paste.init', pipeline factory will ignore to load ratelimit middleware, and print warning message to user for notice ratelimit is deprecated in v3 api. Closes-bug: 1255471 DocImpact Change-Id: Ifeae0504e11089f95e4d8af58bcb7372dca87f81
This commit is contained in:
parent
caa2775793
commit
93d3e47531
@ -1,59 +0,0 @@
|
||||
{
|
||||
"limits": {
|
||||
"rate": [
|
||||
{
|
||||
"limit": [
|
||||
{
|
||||
"next-available": "2013-09-09T13:37:32Z",
|
||||
"remaining": 10,
|
||||
"unit": "MINUTE",
|
||||
"value": 10,
|
||||
"verb": "POST"
|
||||
},
|
||||
{
|
||||
"next-available": "2013-09-09T13:37:32Z",
|
||||
"remaining": 10,
|
||||
"unit": "MINUTE",
|
||||
"value": 10,
|
||||
"verb": "PUT"
|
||||
},
|
||||
{
|
||||
"next-available": "2013-09-09T13:37:32Z",
|
||||
"remaining": 100,
|
||||
"unit": "MINUTE",
|
||||
"value": 100,
|
||||
"verb": "DELETE"
|
||||
}
|
||||
],
|
||||
"regex": ".*",
|
||||
"uri": "*"
|
||||
},
|
||||
{
|
||||
"limit": [
|
||||
{
|
||||
"next-available": "2013-09-09T13:37:32Z",
|
||||
"remaining": 50,
|
||||
"unit": "DAY",
|
||||
"value": 50,
|
||||
"verb": "POST"
|
||||
}
|
||||
],
|
||||
"regex": "^/servers",
|
||||
"uri": "*/servers"
|
||||
},
|
||||
{
|
||||
"limit": [
|
||||
{
|
||||
"next-available": "2013-09-09T13:37:32Z",
|
||||
"remaining": 3,
|
||||
"unit": "MINUTE",
|
||||
"value": 3,
|
||||
"verb": "GET"
|
||||
}
|
||||
],
|
||||
"regex": ".*changes_since.*",
|
||||
"uri": "*changes_since*"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<limits xmlns:atom="http://www.w3.org/2005/Atom" xmlns="http://docs.openstack.org/common/api/v1.0">
|
||||
<rates>
|
||||
<rate regex=".*" uri="*">
|
||||
<limit next-available="2013-09-09T13:37:32Z" unit="MINUTE" verb="POST" remaining="10" value="10"/>
|
||||
<limit next-available="2013-09-09T13:37:32Z" unit="MINUTE" verb="PUT" remaining="10" value="10"/>
|
||||
<limit next-available="2013-09-09T13:37:32Z" unit="MINUTE" verb="DELETE" remaining="100" value="100"/>
|
||||
</rate>
|
||||
<rate regex="^/servers" uri="*/servers">
|
||||
<limit next-available="2013-09-09T13:37:32Z" unit="DAY" verb="POST" remaining="50" value="50"/>
|
||||
</rate>
|
||||
<rate regex=".*changes_since.*" uri="*changes_since*">
|
||||
<limit next-available="2013-09-09T13:37:32Z" unit="MINUTE" verb="GET" remaining="3" value="3"/>
|
||||
</rate>
|
||||
</rates>
|
||||
</limits>
|
@ -70,10 +70,9 @@ keystone = faultwrap sizelimit authtoken keystonecontext ratelimit osapi_compute
|
||||
keystone_nolimit = faultwrap sizelimit authtoken keystonecontext osapi_compute_app_v2
|
||||
|
||||
[composite:openstack_compute_api_v3]
|
||||
use = call:nova.api.auth:pipeline_factory
|
||||
noauth = faultwrap sizelimit noauth_v3 ratelimit_v3 osapi_compute_app_v3
|
||||
keystone = faultwrap sizelimit authtoken keystonecontext ratelimit_v3 osapi_compute_app_v3
|
||||
keystone_nolimit = faultwrap sizelimit authtoken keystonecontext osapi_compute_app_v3
|
||||
use = call:nova.api.auth:pipeline_factory_v3
|
||||
noauth = faultwrap sizelimit noauth_v3 osapi_compute_app_v3
|
||||
keystone = faultwrap sizelimit authtoken keystonecontext osapi_compute_app_v3
|
||||
|
||||
[filter:faultwrap]
|
||||
paste.filter_factory = nova.api.openstack:FaultWrapper.factory
|
||||
@ -87,9 +86,6 @@ paste.filter_factory = nova.api.openstack.auth:NoAuthMiddlewareV3.factory
|
||||
[filter:ratelimit]
|
||||
paste.filter_factory = nova.api.openstack.compute.limits:RateLimitingMiddleware.factory
|
||||
|
||||
[filter:ratelimit_v3]
|
||||
paste.filter_factory = nova.api.openstack.compute.plugins.v3.limits:RateLimitingMiddleware.factory
|
||||
|
||||
[filter:sizelimit]
|
||||
paste.filter_factory = nova.api.sizelimit:RequestBodySizeLimiter.factory
|
||||
|
||||
|
@ -320,8 +320,9 @@
|
||||
# Options defined in nova.api.auth
|
||||
#
|
||||
|
||||
# whether to use per-user rate limiting for the api. (boolean
|
||||
# value)
|
||||
# whether to use per-user rate limiting for the api. This
|
||||
# option is only used by v2 api. rate limiting is removed from
|
||||
# v3 api. (boolean value)
|
||||
#api_rate_limit=false
|
||||
|
||||
# The strategy to use for auth: noauth or keystone. (string
|
||||
|
@ -171,7 +171,6 @@
|
||||
"compute_extension:v3:keypairs:show": "",
|
||||
"compute_extension:v3:keypairs:create": "",
|
||||
"compute_extension:v3:keypairs:delete": "",
|
||||
"compute_extension:v3:limits:discoverable": "",
|
||||
"compute_extension:multinic": "",
|
||||
"compute_extension:v3:os-multinic": "",
|
||||
"compute_extension:v3:os-multinic:discoverable": "",
|
||||
|
@ -32,7 +32,9 @@ from nova import wsgi
|
||||
auth_opts = [
|
||||
cfg.BoolOpt('api_rate_limit',
|
||||
default=False,
|
||||
help='whether to use per-user rate limiting for the api.'),
|
||||
help=('whether to use per-user rate limiting for the api. '
|
||||
'This option is only used by v2 api. rate limiting '
|
||||
'is removed from v3 api.')),
|
||||
cfg.StrOpt('auth_strategy',
|
||||
default='noauth',
|
||||
help='The strategy to use for auth: noauth or keystone.'),
|
||||
@ -48,6 +50,15 @@ CONF.register_opts(auth_opts)
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_pipeline(loader, pipeline):
|
||||
filters = [loader.get_filter(n) for n in pipeline[:-1]]
|
||||
app = loader.get_app(pipeline[-1])
|
||||
filters.reverse()
|
||||
for filter in filters:
|
||||
app = filter(app)
|
||||
return app
|
||||
|
||||
|
||||
def pipeline_factory(loader, global_conf, **local_conf):
|
||||
"""A paste pipeline replica that keys off of auth_strategy."""
|
||||
pipeline = local_conf[CONF.auth_strategy]
|
||||
@ -55,12 +66,18 @@ def pipeline_factory(loader, global_conf, **local_conf):
|
||||
limit_name = CONF.auth_strategy + '_nolimit'
|
||||
pipeline = local_conf.get(limit_name, pipeline)
|
||||
pipeline = pipeline.split()
|
||||
filters = [loader.get_filter(n) for n in pipeline[:-1]]
|
||||
app = loader.get_app(pipeline[-1])
|
||||
filters.reverse()
|
||||
for filter in filters:
|
||||
app = filter(app)
|
||||
return app
|
||||
# NOTE (Alex Xu): This is just for configuration file compatibility.
|
||||
# If the configuration file still contains 'ratelimit_v3', just ignore it.
|
||||
# We will remove this code at next release (J)
|
||||
if 'ratelimit_v3' in pipeline:
|
||||
LOG.warn(_('ratelimit_v3 is removed from v3 api.'))
|
||||
pipeline.remove('ratelimit_v3')
|
||||
return _load_pipeline(loader, pipeline)
|
||||
|
||||
|
||||
def pipeline_factory_v3(loader, global_conf, **local_conf):
|
||||
"""A paste pipeline replica that keys off of auth_strategy."""
|
||||
return _load_pipeline(loader, local_conf[CONF.auth_strategy].split())
|
||||
|
||||
|
||||
class InjectContext(wsgi.Middleware):
|
||||
|
@ -62,7 +62,7 @@ CONF.register_opts(api_opts, api_opts_group)
|
||||
# the core API and so must be present
|
||||
# TODO(cyeoh): Expand this list as the core APIs are ported to V3
|
||||
API_V3_CORE_EXTENSIONS = set(['consoles', 'extensions', 'flavors', 'ips',
|
||||
'limits', 'servers', 'server-metadata',
|
||||
'servers', 'server-metadata',
|
||||
'keypairs', 'console-output', 'versions',
|
||||
'flavor-manage', 'flavor-access',
|
||||
'flavor-extra-specs'])
|
||||
|
@ -1,471 +0,0 @@
|
||||
# Copyright 2011 OpenStack Foundation
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Module dedicated functions/classes dealing with rate limiting requests.
|
||||
"""
|
||||
|
||||
import collections
|
||||
import copy
|
||||
import httplib
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
|
||||
import webob.dec
|
||||
import webob.exc
|
||||
|
||||
from nova.api.openstack.compute.views import limits as limits_views
|
||||
from nova.api.openstack import extensions
|
||||
from nova.api.openstack import wsgi
|
||||
from nova.api.openstack import xmlutil
|
||||
from nova.openstack.common.gettextutils import _
|
||||
from nova.openstack.common import importutils
|
||||
from nova.openstack.common import jsonutils
|
||||
from nova import quota
|
||||
from nova import utils
|
||||
from nova import wsgi as base_wsgi
|
||||
|
||||
|
||||
QUOTAS = quota.QUOTAS
|
||||
LIMITS_PREFIX = "limits."
|
||||
|
||||
|
||||
limits_nsmap = {None: xmlutil.XMLNS_COMMON_V10, 'atom': xmlutil.XMLNS_ATOM}
|
||||
|
||||
|
||||
class LimitsTemplate(xmlutil.TemplateBuilder):
|
||||
def construct(self):
|
||||
root = xmlutil.TemplateElement('limits', selector='limits')
|
||||
|
||||
rates = xmlutil.SubTemplateElement(root, 'rates')
|
||||
rate = xmlutil.SubTemplateElement(rates, 'rate', selector='rate')
|
||||
rate.set('uri', 'uri')
|
||||
rate.set('regex', 'regex')
|
||||
limit = xmlutil.SubTemplateElement(rate, 'limit', selector='limit')
|
||||
limit.set('value', 'value')
|
||||
limit.set('verb', 'verb')
|
||||
limit.set('remaining', 'remaining')
|
||||
limit.set('unit', 'unit')
|
||||
limit.set('next-available', 'next-available')
|
||||
|
||||
return xmlutil.MasterTemplate(root, 1, nsmap=limits_nsmap)
|
||||
|
||||
|
||||
class LimitsController(wsgi.Controller):
|
||||
"""Controller for accessing limits in the OpenStack API."""
|
||||
|
||||
@extensions.expected_errors(())
|
||||
@wsgi.serializers(xml=LimitsTemplate)
|
||||
def index(self, req):
|
||||
"""Return all global and rate limit information."""
|
||||
context = req.environ['nova.context']
|
||||
rate_limits = req.environ.get("nova.limits", [])
|
||||
|
||||
builder = limits_views.ViewBuilderV3()
|
||||
return builder.build(rate_limits)
|
||||
|
||||
|
||||
class Limit(object):
|
||||
"""
|
||||
Stores information about a limit for HTTP requests.
|
||||
"""
|
||||
|
||||
UNITS = dict([(v, k) for k, v in utils.TIME_UNITS.items()])
|
||||
|
||||
def __init__(self, verb, uri, regex, value, unit):
|
||||
"""
|
||||
Initialize a new `Limit`.
|
||||
|
||||
@param verb: HTTP verb (POST, PUT, etc.)
|
||||
@param uri: Human-readable URI
|
||||
@param regex: Regular expression format for this limit
|
||||
@param value: Integer number of requests which can be made
|
||||
@param unit: Unit of measure for the value parameter
|
||||
"""
|
||||
self.verb = verb
|
||||
self.uri = uri
|
||||
self.regex = regex
|
||||
self.value = int(value)
|
||||
self.unit = unit
|
||||
self.unit_string = self.display_unit().lower()
|
||||
self.remaining = int(value)
|
||||
|
||||
if value <= 0:
|
||||
raise ValueError("Limit value must be > 0")
|
||||
|
||||
self.last_request = None
|
||||
self.next_request = None
|
||||
|
||||
self.water_level = 0
|
||||
self.capacity = self.unit
|
||||
self.request_value = float(self.capacity) / float(self.value)
|
||||
msg = _("Only %(value)s %(verb)s request(s) can be "
|
||||
"made to %(uri)s every %(unit_string)s.")
|
||||
self.error_message = msg % self.__dict__
|
||||
|
||||
def __call__(self, verb, url):
|
||||
"""
|
||||
Represents a call to this limit from a relevant request.
|
||||
|
||||
@param verb: string http verb (POST, GET, etc.)
|
||||
@param url: string URL
|
||||
"""
|
||||
if self.verb != verb or not re.match(self.regex, url):
|
||||
return
|
||||
|
||||
now = self._get_time()
|
||||
|
||||
if self.last_request is None:
|
||||
self.last_request = now
|
||||
|
||||
leak_value = now - self.last_request
|
||||
|
||||
self.water_level -= leak_value
|
||||
self.water_level = max(self.water_level, 0)
|
||||
self.water_level += self.request_value
|
||||
|
||||
difference = self.water_level - self.capacity
|
||||
|
||||
self.last_request = now
|
||||
|
||||
if difference > 0:
|
||||
self.water_level -= self.request_value
|
||||
self.next_request = now + difference
|
||||
return difference
|
||||
|
||||
cap = self.capacity
|
||||
water = self.water_level
|
||||
val = self.value
|
||||
|
||||
self.remaining = math.floor(((cap - water) / cap) * val)
|
||||
self.next_request = now
|
||||
|
||||
def _get_time(self):
|
||||
"""Retrieve the current time. Broken out for testability."""
|
||||
return time.time()
|
||||
|
||||
def display_unit(self):
|
||||
"""Display the string name of the unit."""
|
||||
return self.UNITS.get(self.unit, "UNKNOWN")
|
||||
|
||||
def display(self):
|
||||
"""Return a useful representation of this class."""
|
||||
return {
|
||||
"verb": self.verb,
|
||||
"URI": self.uri,
|
||||
"regex": self.regex,
|
||||
"value": self.value,
|
||||
"remaining": int(self.remaining),
|
||||
"unit": self.display_unit(),
|
||||
"resetTime": int(self.next_request or self._get_time()),
|
||||
}
|
||||
|
||||
# "Limit" format is a dictionary with the HTTP verb, human-readable URI,
|
||||
# a regular-expression to match, value and unit of measure (PER_DAY, etc.)
|
||||
|
||||
DEFAULT_LIMITS = [
|
||||
Limit("POST", "*", ".*", 10, utils.TIME_UNITS['MINUTE']),
|
||||
Limit("POST", "*/servers", "^/servers", 50, utils.TIME_UNITS['DAY']),
|
||||
Limit("PUT", "*", ".*", 10, utils.TIME_UNITS['MINUTE']),
|
||||
Limit("GET", "*changes_since*", ".*changes_since.*", 3,
|
||||
utils.TIME_UNITS['MINUTE']),
|
||||
Limit("DELETE", "*", ".*", 100, utils.TIME_UNITS['MINUTE']),
|
||||
]
|
||||
|
||||
|
||||
class RateLimitingMiddleware(base_wsgi.Middleware):
|
||||
"""
|
||||
Rate-limits requests passing through this middleware. All limit information
|
||||
is stored in memory for this implementation.
|
||||
"""
|
||||
|
||||
def __init__(self, application, limits=None, limiter=None, **kwargs):
|
||||
"""
|
||||
Initialize new `RateLimitingMiddleware`, which wraps the given WSGI
|
||||
application and sets up the given limits.
|
||||
|
||||
@param application: WSGI application to wrap
|
||||
@param limits: String describing limits
|
||||
@param limiter: String identifying class for representing limits
|
||||
|
||||
Other parameters are passed to the constructor for the limiter.
|
||||
"""
|
||||
base_wsgi.Middleware.__init__(self, application)
|
||||
|
||||
# Select the limiter class
|
||||
if limiter is None:
|
||||
limiter = Limiter
|
||||
else:
|
||||
limiter = importutils.import_class(limiter)
|
||||
|
||||
# Parse the limits, if any are provided
|
||||
if limits is not None:
|
||||
limits = limiter.parse_limits(limits)
|
||||
|
||||
self._limiter = limiter(limits or DEFAULT_LIMITS, **kwargs)
|
||||
|
||||
@webob.dec.wsgify(RequestClass=wsgi.Request)
|
||||
def __call__(self, req):
|
||||
"""
|
||||
Represents a single call through this middleware. We should record the
|
||||
request if we have a limit relevant to it. If no limit is relevant to
|
||||
the request, ignore it.
|
||||
|
||||
If the request should be rate limited, return a fault telling the user
|
||||
they are over the limit and need to retry later.
|
||||
"""
|
||||
verb = req.method
|
||||
url = req.url
|
||||
context = req.environ.get("nova.context")
|
||||
|
||||
if context:
|
||||
username = context.user_id
|
||||
else:
|
||||
username = None
|
||||
|
||||
delay, error = self._limiter.check_for_delay(verb, url, username)
|
||||
|
||||
if delay:
|
||||
msg = _("This request was rate-limited.")
|
||||
retry = time.time() + delay
|
||||
return wsgi.RateLimitFault(msg, error, retry)
|
||||
|
||||
req.environ["nova.limits"] = self._limiter.get_limits(username)
|
||||
|
||||
return self.application
|
||||
|
||||
|
||||
class Limiter(object):
|
||||
"""
|
||||
Rate-limit checking class which handles limits in memory.
|
||||
"""
|
||||
|
||||
def __init__(self, limits, **kwargs):
|
||||
"""
|
||||
Initialize the new `Limiter`.
|
||||
|
||||
@param limits: List of `Limit` objects
|
||||
"""
|
||||
self.limits = copy.deepcopy(limits)
|
||||
self.levels = collections.defaultdict(lambda: copy.deepcopy(limits))
|
||||
|
||||
# Pick up any per-user limit information
|
||||
for key, value in kwargs.items():
|
||||
if key.startswith(LIMITS_PREFIX):
|
||||
username = key[len(LIMITS_PREFIX):]
|
||||
self.levels[username] = self.parse_limits(value)
|
||||
|
||||
def get_limits(self, username=None):
|
||||
"""
|
||||
Return the limits for a given user.
|
||||
"""
|
||||
return [limit.display() for limit in self.levels[username]]
|
||||
|
||||
def check_for_delay(self, verb, url, username=None):
|
||||
"""
|
||||
Check the given verb/user/user triplet for limit.
|
||||
|
||||
@return: Tuple of delay (in seconds) and error message (or None, None)
|
||||
"""
|
||||
delays = []
|
||||
|
||||
for limit in self.levels[username]:
|
||||
delay = limit(verb, url)
|
||||
if delay:
|
||||
delays.append((delay, limit.error_message))
|
||||
|
||||
if delays:
|
||||
delays.sort()
|
||||
return delays[0]
|
||||
|
||||
return None, None
|
||||
|
||||
# Note: This method gets called before the class is instantiated,
|
||||
# so this must be either a static method or a class method. It is
|
||||
# used to develop a list of limits to feed to the constructor. We
|
||||
# put this in the class so that subclasses can override the
|
||||
# default limit parsing.
|
||||
@staticmethod
|
||||
def parse_limits(limits):
|
||||
"""
|
||||
Convert a string into a list of Limit instances. This
|
||||
implementation expects a semicolon-separated sequence of
|
||||
parenthesized groups, where each group contains a
|
||||
comma-separated sequence consisting of HTTP method,
|
||||
user-readable URI, a URI reg-exp, an integer number of
|
||||
requests which can be made, and a unit of measure. Valid
|
||||
values for the latter are "SECOND", "MINUTE", "HOUR", and
|
||||
"DAY".
|
||||
|
||||
@return: List of Limit instances.
|
||||
"""
|
||||
|
||||
# Handle empty limit strings
|
||||
limits = limits.strip()
|
||||
if not limits:
|
||||
return []
|
||||
|
||||
# Split up the limits by semicolon
|
||||
result = []
|
||||
for group in limits.split(';'):
|
||||
group = group.strip()
|
||||
if group[:1] != '(' or group[-1:] != ')':
|
||||
raise ValueError("Limit rules must be surrounded by "
|
||||
"parentheses")
|
||||
group = group[1:-1]
|
||||
|
||||
# Extract the Limit arguments
|
||||
args = [a.strip() for a in group.split(',')]
|
||||
if len(args) != 5:
|
||||
raise ValueError("Limit rules must contain the following "
|
||||
"arguments: verb, uri, regex, value, unit")
|
||||
|
||||
# Pull out the arguments
|
||||
verb, uri, regex, value, unit = args
|
||||
|
||||
# Upper-case the verb
|
||||
verb = verb.upper()
|
||||
|
||||
# Convert value--raises ValueError if it's not integer
|
||||
value = int(value)
|
||||
|
||||
# Convert unit
|
||||
unit = unit.upper()
|
||||
if unit not in utils.TIME_UNITS:
|
||||
raise ValueError("Invalid units specified")
|
||||
unit = utils.TIME_UNITS[unit]
|
||||
|
||||
# Build a limit
|
||||
result.append(Limit(verb, uri, regex, value, unit))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class WsgiLimiter(object):
|
||||
"""
|
||||
Rate-limit checking from a WSGI application. Uses an in-memory `Limiter`.
|
||||
|
||||
To use, POST ``/<username>`` with JSON data such as::
|
||||
|
||||
{
|
||||
"verb" : GET,
|
||||
"path" : "/servers"
|
||||
}
|
||||
|
||||
and receive a 204 No Content, or a 403 Forbidden with an X-Wait-Seconds
|
||||
header containing the number of seconds to wait before the action would
|
||||
succeed.
|
||||
"""
|
||||
|
||||
def __init__(self, limits=None):
|
||||
"""
|
||||
Initialize the new `WsgiLimiter`.
|
||||
|
||||
@param limits: List of `Limit` objects
|
||||
"""
|
||||
self._limiter = Limiter(limits or DEFAULT_LIMITS)
|
||||
|
||||
@webob.dec.wsgify(RequestClass=wsgi.Request)
|
||||
def __call__(self, request):
|
||||
"""
|
||||
Handles a call to this application. Returns 204 if the request is
|
||||
acceptable to the limiter, else a 403 is returned with a relevant
|
||||
header indicating when the request *will* succeed.
|
||||
"""
|
||||
if request.method != "POST":
|
||||
raise webob.exc.HTTPMethodNotAllowed()
|
||||
|
||||
try:
|
||||
info = dict(jsonutils.loads(request.body))
|
||||
except ValueError:
|
||||
raise webob.exc.HTTPBadRequest()
|
||||
|
||||
username = request.path_info_pop()
|
||||
verb = info.get("verb")
|
||||
path = info.get("path")
|
||||
|
||||
delay, error = self._limiter.check_for_delay(verb, path, username)
|
||||
|
||||
if delay:
|
||||
headers = {"X-Wait-Seconds": "%.2f" % delay}
|
||||
return webob.exc.HTTPForbidden(headers=headers, explanation=error)
|
||||
else:
|
||||
return webob.exc.HTTPNoContent()
|
||||
|
||||
|
||||
class WsgiLimiterProxy(object):
|
||||
"""
|
||||
Rate-limit requests based on answers from a remote source.
|
||||
"""
|
||||
|
||||
def __init__(self, limiter_address):
|
||||
"""
|
||||
Initialize the new `WsgiLimiterProxy`.
|
||||
|
||||
@param limiter_address: IP/port combination of where to request limit
|
||||
"""
|
||||
self.limiter_address = limiter_address
|
||||
|
||||
def check_for_delay(self, verb, path, username=None):
|
||||
body = jsonutils.dumps({"verb": verb, "path": path})
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
conn = httplib.HTTPConnection(self.limiter_address)
|
||||
|
||||
if username:
|
||||
conn.request("POST", "/%s" % (username), body, headers)
|
||||
else:
|
||||
conn.request("POST", "/", body, headers)
|
||||
|
||||
resp = conn.getresponse()
|
||||
|
||||
if 200 >= resp.status < 300:
|
||||
return None, None
|
||||
|
||||
return resp.getheader("X-Wait-Seconds"), resp.read() or None
|
||||
|
||||
# Note: This method gets called before the class is instantiated,
|
||||
# so this must be either a static method or a class method. It is
|
||||
# used to develop a list of limits to feed to the constructor.
|
||||
# This implementation returns an empty list, since all limit
|
||||
# decisions are made by a remote server.
|
||||
@staticmethod
|
||||
def parse_limits(limits):
|
||||
"""
|
||||
Ignore a limits string--simply doesn't apply for the limit
|
||||
proxy.
|
||||
|
||||
@return: Empty list.
|
||||
"""
|
||||
|
||||
return []
|
||||
|
||||
|
||||
class Limits(extensions.V3APIExtensionBase):
|
||||
"""Limits rate core API."""
|
||||
name = "Limits"
|
||||
alias = "limits"
|
||||
namespace = "http://docs.openstack.org/compute/core/limits/v3"
|
||||
version = 1
|
||||
|
||||
def get_resources(self):
|
||||
resources = [extensions.ResourceExtension(self.alias,
|
||||
LimitsController(),
|
||||
member_name='limit')]
|
||||
return resources
|
||||
|
||||
def get_controller_extensions(self):
|
||||
return []
|
@ -1,867 +0,0 @@
|
||||
# Copyright 2011 OpenStack Foundation
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Tests dealing with HTTP rate-limiting.
|
||||
"""
|
||||
|
||||
import httplib
|
||||
import StringIO
|
||||
from xml.dom import minidom
|
||||
|
||||
from lxml import etree
|
||||
import webob
|
||||
|
||||
from nova.api.openstack.compute.plugins.v3 import limits
|
||||
from nova.api.openstack.compute import views
|
||||
from nova.api.openstack import xmlutil
|
||||
import nova.context
|
||||
from nova.openstack.common import jsonutils
|
||||
from nova import test
|
||||
from nova.tests import matchers
|
||||
from nova import utils
|
||||
|
||||
|
||||
TEST_LIMITS = [
|
||||
limits.Limit("GET", "/delayed", "^/delayed", 1,
|
||||
utils.TIME_UNITS['MINUTE']),
|
||||
limits.Limit("POST", "*", ".*", 7, utils.TIME_UNITS['MINUTE']),
|
||||
limits.Limit("POST", "/servers", "^/servers", 3,
|
||||
utils.TIME_UNITS['MINUTE']),
|
||||
limits.Limit("PUT", "*", "", 10, utils.TIME_UNITS['MINUTE']),
|
||||
limits.Limit("PUT", "/servers", "^/servers", 5,
|
||||
utils.TIME_UNITS['MINUTE']),
|
||||
]
|
||||
NS = {
|
||||
'atom': 'http://www.w3.org/2005/Atom',
|
||||
'ns': 'http://docs.openstack.org/common/api/v1.0'
|
||||
}
|
||||
|
||||
|
||||
class BaseLimitTestSuite(test.NoDBTestCase):
|
||||
"""Base test suite which provides relevant stubs and time abstraction."""
|
||||
|
||||
def setUp(self):
|
||||
super(BaseLimitTestSuite, self).setUp()
|
||||
self.time = 0.0
|
||||
self.stubs.Set(limits.Limit, "_get_time", self._get_time)
|
||||
|
||||
def _get_time(self):
|
||||
"""Return the "time" according to this test suite."""
|
||||
return self.time
|
||||
|
||||
|
||||
class LimitsControllerTest(BaseLimitTestSuite):
|
||||
"""
|
||||
Tests for `limits.LimitsController` class.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""Run before each test."""
|
||||
super(LimitsControllerTest, self).setUp()
|
||||
self.controller = limits.LimitsController()
|
||||
|
||||
def _get_index_request(self, accept_header="application/json"):
|
||||
"""Helper to set routing arguments."""
|
||||
request = webob.Request.blank("/")
|
||||
request.accept = accept_header
|
||||
request.environ["wsgiorg.routing_args"] = (None, {
|
||||
"action": "index",
|
||||
"controller": "",
|
||||
})
|
||||
context = nova.context.RequestContext('testuser', 'testproject')
|
||||
request.environ["nova.context"] = context
|
||||
return request
|
||||
|
||||
def _populate_limits(self, request):
|
||||
"""Put limit info into a request."""
|
||||
_limits = [
|
||||
limits.Limit("GET", "*", ".*", 10, 60).display(),
|
||||
limits.Limit("POST", "*", ".*", 5, 60 * 60).display(),
|
||||
limits.Limit("GET", "changes-since*", "changes-since",
|
||||
5, 60).display(),
|
||||
]
|
||||
request.environ["nova.limits"] = _limits
|
||||
return request
|
||||
|
||||
def test_empty_index_json(self):
|
||||
# Test getting empty limit details in JSON.
|
||||
request = self._get_index_request()
|
||||
body = self.controller.index(request)
|
||||
expected = {
|
||||
"limits": {
|
||||
"rate": []
|
||||
},
|
||||
}
|
||||
self.assertEqual(expected, body)
|
||||
|
||||
def test_index_json(self):
|
||||
# Test getting limit details in JSON.
|
||||
request = self._get_index_request()
|
||||
request = self._populate_limits(request)
|
||||
body = self.controller.index(request)
|
||||
expected = {
|
||||
"limits": {
|
||||
"rate": [
|
||||
{
|
||||
"regex": ".*",
|
||||
"uri": "*",
|
||||
"limit": [
|
||||
{
|
||||
"verb": "GET",
|
||||
"next-available": "1970-01-01T00:00:00Z",
|
||||
"unit": "MINUTE",
|
||||
"value": 10,
|
||||
"remaining": 10,
|
||||
},
|
||||
{
|
||||
"verb": "POST",
|
||||
"next-available": "1970-01-01T00:00:00Z",
|
||||
"unit": "HOUR",
|
||||
"value": 5,
|
||||
"remaining": 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"regex": "changes-since",
|
||||
"uri": "changes-since*",
|
||||
"limit": [
|
||||
{
|
||||
"verb": "GET",
|
||||
"next-available": "1970-01-01T00:00:00Z",
|
||||
"unit": "MINUTE",
|
||||
"value": 5,
|
||||
"remaining": 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
]
|
||||
},
|
||||
}
|
||||
self.assertEqual(expected, body)
|
||||
|
||||
def _populate_limits_diff_regex(self, request):
|
||||
"""Put limit info into a request."""
|
||||
_limits = [
|
||||
limits.Limit("GET", "*", ".*", 10, 60).display(),
|
||||
limits.Limit("GET", "*", "*.*", 10, 60).display(),
|
||||
]
|
||||
request.environ["nova.limits"] = _limits
|
||||
return request
|
||||
|
||||
def test_index_diff_regex(self):
|
||||
# Test getting limit details in JSON.
|
||||
request = self._get_index_request()
|
||||
request = self._populate_limits_diff_regex(request)
|
||||
body = self.controller.index(request)
|
||||
expected = {
|
||||
"limits": {
|
||||
"rate": [
|
||||
{
|
||||
"regex": ".*",
|
||||
"uri": "*",
|
||||
"limit": [
|
||||
{
|
||||
"verb": "GET",
|
||||
"next-available": "1970-01-01T00:00:00Z",
|
||||
"unit": "MINUTE",
|
||||
"value": 10,
|
||||
"remaining": 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"regex": "*.*",
|
||||
"uri": "*",
|
||||
"limit": [
|
||||
{
|
||||
"verb": "GET",
|
||||
"next-available": "1970-01-01T00:00:00Z",
|
||||
"unit": "MINUTE",
|
||||
"value": 10,
|
||||
"remaining": 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
]
|
||||
},
|
||||
}
|
||||
self.assertEqual(expected, body)
|
||||
|
||||
|
||||
class MockLimiter(limits.Limiter):
|
||||
pass
|
||||
|
||||
|
||||
class LimitMiddlewareTest(BaseLimitTestSuite):
|
||||
"""
|
||||
Tests for the `limits.RateLimitingMiddleware` class.
|
||||
"""
|
||||
|
||||
@webob.dec.wsgify
|
||||
def _empty_app(self, request):
|
||||
"""Do-nothing WSGI app."""
|
||||
pass
|
||||
|
||||
def setUp(self):
|
||||
"""Prepare middleware for use through fake WSGI app."""
|
||||
super(LimitMiddlewareTest, self).setUp()
|
||||
_limits = '(GET, *, .*, 1, MINUTE)'
|
||||
self.app = limits.RateLimitingMiddleware(self._empty_app, _limits,
|
||||
"%s.MockLimiter" %
|
||||
self.__class__.__module__)
|
||||
|
||||
def test_limit_class(self):
|
||||
# Test that middleware selected correct limiter class.
|
||||
self.assertIsInstance(self.app._limiter, MockLimiter)
|
||||
|
||||
def test_good_request(self):
|
||||
# Test successful GET request through middleware.
|
||||
request = webob.Request.blank("/")
|
||||
response = request.get_response(self.app)
|
||||
self.assertEqual(200, response.status_int)
|
||||
|
||||
def test_limited_request_json(self):
|
||||
# Test a rate-limited (429) GET request through middleware.
|
||||
request = webob.Request.blank("/")
|
||||
response = request.get_response(self.app)
|
||||
self.assertEqual(200, response.status_int)
|
||||
|
||||
request = webob.Request.blank("/")
|
||||
response = request.get_response(self.app)
|
||||
self.assertEqual(response.status_int, 429)
|
||||
|
||||
self.assertIn('Retry-After', response.headers)
|
||||
retry_after = int(response.headers['Retry-After'])
|
||||
self.assertAlmostEqual(retry_after, 60, 1)
|
||||
|
||||
body = jsonutils.loads(response.body)
|
||||
expected = "Only 1 GET request(s) can be made to * every minute."
|
||||
value = body["overLimit"]["details"].strip()
|
||||
self.assertEqual(value, expected)
|
||||
|
||||
self.assertIn("retryAfter", body["overLimit"])
|
||||
retryAfter = body["overLimit"]["retryAfter"]
|
||||
self.assertEqual(retryAfter, "60")
|
||||
|
||||
def test_limited_request_xml(self):
|
||||
# Test a rate-limited (429) response as XML.
|
||||
request = webob.Request.blank("/")
|
||||
response = request.get_response(self.app)
|
||||
self.assertEqual(200, response.status_int)
|
||||
|
||||
request = webob.Request.blank("/")
|
||||
request.accept = "application/xml"
|
||||
response = request.get_response(self.app)
|
||||
self.assertEqual(response.status_int, 429)
|
||||
|
||||
root = minidom.parseString(response.body).childNodes[0]
|
||||
expected = "Only 1 GET request(s) can be made to * every minute."
|
||||
|
||||
self.assertIsNotNone(root.attributes.getNamedItem("retryAfter"))
|
||||
retryAfter = root.attributes.getNamedItem("retryAfter").value
|
||||
self.assertEqual(retryAfter, "60")
|
||||
|
||||
details = root.getElementsByTagName("details")
|
||||
self.assertEqual(details.length, 1)
|
||||
|
||||
value = details.item(0).firstChild.data.strip()
|
||||
self.assertEqual(value, expected)
|
||||
|
||||
|
||||
class LimitTest(BaseLimitTestSuite):
|
||||
"""
|
||||
Tests for the `limits.Limit` class.
|
||||
"""
|
||||
|
||||
def test_GET_no_delay(self):
|
||||
# Test a limit handles 1 GET per second.
|
||||
limit = limits.Limit("GET", "*", ".*", 1, 1)
|
||||
delay = limit("GET", "/anything")
|
||||
self.assertIsNone(delay)
|
||||
self.assertEqual(0, limit.next_request)
|
||||
self.assertEqual(0, limit.last_request)
|
||||
|
||||
def test_GET_delay(self):
|
||||
# Test two calls to 1 GET per second limit.
|
||||
limit = limits.Limit("GET", "*", ".*", 1, 1)
|
||||
delay = limit("GET", "/anything")
|
||||
self.assertIsNone(delay)
|
||||
|
||||
delay = limit("GET", "/anything")
|
||||
self.assertEqual(1, delay)
|
||||
self.assertEqual(1, limit.next_request)
|
||||
self.assertEqual(0, limit.last_request)
|
||||
|
||||
self.time += 4
|
||||
|
||||
delay = limit("GET", "/anything")
|
||||
self.assertIsNone(delay)
|
||||
self.assertEqual(4, limit.next_request)
|
||||
self.assertEqual(4, limit.last_request)
|
||||
|
||||
|
||||
class ParseLimitsTest(BaseLimitTestSuite):
|
||||
"""
|
||||
Tests for the default limits parser in the in-memory
|
||||
`limits.Limiter` class.
|
||||
"""
|
||||
|
||||
def test_invalid(self):
|
||||
# Test that parse_limits() handles invalid input correctly.
|
||||
self.assertRaises(ValueError, limits.Limiter.parse_limits,
|
||||
';;;;;')
|
||||
|
||||
def test_bad_rule(self):
|
||||
# Test that parse_limits() handles bad rules correctly.
|
||||
self.assertRaises(ValueError, limits.Limiter.parse_limits,
|
||||
'GET, *, .*, 20, minute')
|
||||
|
||||
def test_missing_arg(self):
|
||||
# Test that parse_limits() handles missing args correctly.
|
||||
self.assertRaises(ValueError, limits.Limiter.parse_limits,
|
||||
'(GET, *, .*, 20)')
|
||||
|
||||
def test_bad_value(self):
|
||||
# Test that parse_limits() handles bad values correctly.
|
||||
self.assertRaises(ValueError, limits.Limiter.parse_limits,
|
||||
'(GET, *, .*, foo, minute)')
|
||||
|
||||
def test_bad_unit(self):
|
||||
# Test that parse_limits() handles bad units correctly.
|
||||
self.assertRaises(ValueError, limits.Limiter.parse_limits,
|
||||
'(GET, *, .*, 20, lightyears)')
|
||||
|
||||
def test_multiple_rules(self):
|
||||
# Test that parse_limits() handles multiple rules correctly.
|
||||
try:
|
||||
l = limits.Limiter.parse_limits('(get, *, .*, 20, minute);'
|
||||
'(PUT, /foo*, /foo.*, 10, hour);'
|
||||
'(POST, /bar*, /bar.*, 5, second);'
|
||||
'(Say, /derp*, /derp.*, 1, day)')
|
||||
except ValueError as e:
|
||||
assert False, str(e)
|
||||
|
||||
# Make sure the number of returned limits are correct
|
||||
self.assertEqual(len(l), 4)
|
||||
|
||||
# Check all the verbs...
|
||||
expected = ['GET', 'PUT', 'POST', 'SAY']
|
||||
self.assertEqual([t.verb for t in l], expected)
|
||||
|
||||
# ...the URIs...
|
||||
expected = ['*', '/foo*', '/bar*', '/derp*']
|
||||
self.assertEqual([t.uri for t in l], expected)
|
||||
|
||||
# ...the regexes...
|
||||
expected = ['.*', '/foo.*', '/bar.*', '/derp.*']
|
||||
self.assertEqual([t.regex for t in l], expected)
|
||||
|
||||
# ...the values...
|
||||
expected = [20, 10, 5, 1]
|
||||
self.assertEqual([t.value for t in l], expected)
|
||||
|
||||
# ...and the units...
|
||||
expected = [utils.TIME_UNITS['MINUTE'], utils.TIME_UNITS['HOUR'],
|
||||
utils.TIME_UNITS['SECOND'], utils.TIME_UNITS['DAY']]
|
||||
self.assertEqual([t.unit for t in l], expected)
|
||||
|
||||
|
||||
class LimiterTest(BaseLimitTestSuite):
|
||||
"""
|
||||
Tests for the in-memory `limits.Limiter` class.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""Run before each test."""
|
||||
super(LimiterTest, self).setUp()
|
||||
userlimits = {'limits.user3': '',
|
||||
'limits.user0': '(get, *, .*, 4, minute);'
|
||||
'(put, *, .*, 2, minute)'}
|
||||
self.limiter = limits.Limiter(TEST_LIMITS, **userlimits)
|
||||
|
||||
def _check(self, num, verb, url, username=None):
|
||||
"""Check and yield results from checks."""
|
||||
for x in xrange(num):
|
||||
yield self.limiter.check_for_delay(verb, url, username)[0]
|
||||
|
||||
def _check_sum(self, num, verb, url, username=None):
|
||||
"""Check and sum results from checks."""
|
||||
results = self._check(num, verb, url, username)
|
||||
return sum(item for item in results if item)
|
||||
|
||||
def test_no_delay_GET(self):
|
||||
"""
|
||||
Simple test to ensure no delay on a single call for a limit verb we
|
||||
didn"t set.
|
||||
"""
|
||||
delay = self.limiter.check_for_delay("GET", "/anything")
|
||||
self.assertEqual(delay, (None, None))
|
||||
|
||||
def test_no_delay_PUT(self):
|
||||
# Simple test to ensure no delay on a single call for a known limit.
|
||||
delay = self.limiter.check_for_delay("PUT", "/anything")
|
||||
self.assertEqual(delay, (None, None))
|
||||
|
||||
def test_delay_PUT(self):
|
||||
"""
|
||||
Ensure the 11th PUT will result in a delay of 6.0 seconds until
|
||||
the next request will be granced.
|
||||
"""
|
||||
expected = [None] * 10 + [6.0]
|
||||
results = list(self._check(11, "PUT", "/anything"))
|
||||
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
def test_delay_POST(self):
|
||||
"""
|
||||
Ensure the 8th POST will result in a delay of 6.0 seconds until
|
||||
the next request will be granced.
|
||||
"""
|
||||
expected = [None] * 7
|
||||
results = list(self._check(7, "POST", "/anything"))
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
expected = 60.0 / 7.0
|
||||
results = self._check_sum(1, "POST", "/anything")
|
||||
self.assertAlmostEqual(expected, results, 8)
|
||||
|
||||
def test_delay_GET(self):
|
||||
# Ensure the 11th GET will result in NO delay.
|
||||
expected = [None] * 11
|
||||
results = list(self._check(11, "GET", "/anything"))
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
expected = [None] * 4 + [15.0]
|
||||
results = list(self._check(5, "GET", "/foo", "user0"))
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
def test_delay_PUT_servers(self):
|
||||
"""
|
||||
Ensure PUT on /servers limits at 5 requests, and PUT elsewhere is still
|
||||
OK after 5 requests...but then after 11 total requests, PUT limiting
|
||||
kicks in.
|
||||
"""
|
||||
# First 6 requests on PUT /servers
|
||||
expected = [None] * 5 + [12.0]
|
||||
results = list(self._check(6, "PUT", "/servers"))
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
# Next 5 request on PUT /anything
|
||||
expected = [None] * 4 + [6.0]
|
||||
results = list(self._check(5, "PUT", "/anything"))
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
def test_delay_PUT_wait(self):
|
||||
"""
|
||||
Ensure after hitting the limit and then waiting for the correct
|
||||
amount of time, the limit will be lifted.
|
||||
"""
|
||||
expected = [None] * 10 + [6.0]
|
||||
results = list(self._check(11, "PUT", "/anything"))
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
# Advance time
|
||||
self.time += 6.0
|
||||
|
||||
expected = [None, 6.0]
|
||||
results = list(self._check(2, "PUT", "/anything"))
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
def test_multiple_delays(self):
|
||||
# Ensure multiple requests still get a delay.
|
||||
expected = [None] * 10 + [6.0] * 10
|
||||
results = list(self._check(20, "PUT", "/anything"))
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
self.time += 1.0
|
||||
|
||||
expected = [5.0] * 10
|
||||
results = list(self._check(10, "PUT", "/anything"))
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
expected = [None] * 2 + [30.0] * 8
|
||||
results = list(self._check(10, "PUT", "/anything", "user0"))
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
def test_user_limit(self):
|
||||
# Test user-specific limits.
|
||||
self.assertEqual(self.limiter.levels['user3'], [])
|
||||
self.assertEqual(len(self.limiter.levels['user0']), 2)
|
||||
|
||||
def test_multiple_users(self):
|
||||
# Tests involving multiple users.
|
||||
# User0
|
||||
expected = [None] * 2 + [30.0] * 8
|
||||
results = list(self._check(10, "PUT", "/anything", "user0"))
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
# User1
|
||||
expected = [None] * 10 + [6.0] * 10
|
||||
results = list(self._check(20, "PUT", "/anything", "user1"))
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
# User2
|
||||
expected = [None] * 10 + [6.0] * 5
|
||||
results = list(self._check(15, "PUT", "/anything", "user2"))
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
# User3
|
||||
expected = [None] * 20
|
||||
results = list(self._check(20, "PUT", "/anything", "user3"))
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
self.time += 1.0
|
||||
|
||||
# User1 again
|
||||
expected = [5.0] * 10
|
||||
results = list(self._check(10, "PUT", "/anything", "user1"))
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
self.time += 1.0
|
||||
|
||||
# User1 again
|
||||
expected = [4.0] * 5
|
||||
results = list(self._check(5, "PUT", "/anything", "user2"))
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
# User0 again
|
||||
expected = [28.0]
|
||||
results = list(self._check(1, "PUT", "/anything", "user0"))
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
self.time += 28.0
|
||||
|
||||
expected = [None, 30.0]
|
||||
results = list(self._check(2, "PUT", "/anything", "user0"))
|
||||
self.assertEqual(expected, results)
|
||||
|
||||
|
||||
class WsgiLimiterTest(BaseLimitTestSuite):
|
||||
"""
|
||||
Tests for `limits.WsgiLimiter` class.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""Run before each test."""
|
||||
super(WsgiLimiterTest, self).setUp()
|
||||
self.app = limits.WsgiLimiter(TEST_LIMITS)
|
||||
|
||||
def _request_data(self, verb, path):
|
||||
"""Get data describing a limit request verb/path."""
|
||||
return jsonutils.dumps({"verb": verb, "path": path})
|
||||
|
||||
def _request(self, verb, url, username=None):
|
||||
"""Make sure that POSTing to the given url causes the given username
|
||||
to perform the given action. Make the internal rate limiter return
|
||||
delay and make sure that the WSGI app returns the correct response.
|
||||
"""
|
||||
if username:
|
||||
request = webob.Request.blank("/%s" % username)
|
||||
else:
|
||||
request = webob.Request.blank("/")
|
||||
|
||||
request.method = "POST"
|
||||
request.body = self._request_data(verb, url)
|
||||
response = request.get_response(self.app)
|
||||
|
||||
if "X-Wait-Seconds" in response.headers:
|
||||
self.assertEqual(response.status_int, 403)
|
||||
return response.headers["X-Wait-Seconds"]
|
||||
|
||||
self.assertEqual(response.status_int, 204)
|
||||
|
||||
def test_invalid_methods(self):
|
||||
# Only POSTs should work.
|
||||
for method in ["GET", "PUT", "DELETE", "HEAD", "OPTIONS"]:
|
||||
request = webob.Request.blank("/", method=method)
|
||||
response = request.get_response(self.app)
|
||||
self.assertEqual(response.status_int, 405)
|
||||
|
||||
def test_good_url(self):
|
||||
delay = self._request("GET", "/something")
|
||||
self.assertIsNone(delay)
|
||||
|
||||
def test_escaping(self):
|
||||
delay = self._request("GET", "/something/jump%20up")
|
||||
self.assertIsNone(delay)
|
||||
|
||||
def test_response_to_delays(self):
|
||||
delay = self._request("GET", "/delayed")
|
||||
self.assertIsNone(delay)
|
||||
|
||||
delay = self._request("GET", "/delayed")
|
||||
self.assertEqual(delay, '60.00')
|
||||
|
||||
def test_response_to_delays_usernames(self):
|
||||
delay = self._request("GET", "/delayed", "user1")
|
||||
self.assertIsNone(delay)
|
||||
|
||||
delay = self._request("GET", "/delayed", "user2")
|
||||
self.assertIsNone(delay)
|
||||
|
||||
delay = self._request("GET", "/delayed", "user1")
|
||||
self.assertEqual(delay, '60.00')
|
||||
|
||||
delay = self._request("GET", "/delayed", "user2")
|
||||
self.assertEqual(delay, '60.00')
|
||||
|
||||
|
||||
class FakeHttplibSocket(object):
|
||||
"""
|
||||
Fake `httplib.HTTPResponse` replacement.
|
||||
"""
|
||||
|
||||
def __init__(self, response_string):
|
||||
"""Initialize new `FakeHttplibSocket`."""
|
||||
self._buffer = StringIO.StringIO(response_string)
|
||||
|
||||
def makefile(self, _mode, _other):
|
||||
"""Returns the socket's internal buffer."""
|
||||
return self._buffer
|
||||
|
||||
|
||||
class FakeHttplibConnection(object):
|
||||
"""
|
||||
Fake `httplib.HTTPConnection`.
|
||||
"""
|
||||
|
||||
def __init__(self, app, host):
|
||||
"""
|
||||
Initialize `FakeHttplibConnection`.
|
||||
"""
|
||||
self.app = app
|
||||
self.host = host
|
||||
|
||||
def request(self, method, path, body="", headers=None):
|
||||
"""
|
||||
Requests made via this connection actually get translated and routed
|
||||
into our WSGI app, we then wait for the response and turn it back into
|
||||
an `httplib.HTTPResponse`.
|
||||
"""
|
||||
if not headers:
|
||||
headers = {}
|
||||
|
||||
req = webob.Request.blank(path)
|
||||
req.method = method
|
||||
req.headers = headers
|
||||
req.host = self.host
|
||||
req.body = body
|
||||
|
||||
resp = str(req.get_response(self.app))
|
||||
resp = "HTTP/1.0 %s" % resp
|
||||
sock = FakeHttplibSocket(resp)
|
||||
self.http_response = httplib.HTTPResponse(sock)
|
||||
self.http_response.begin()
|
||||
|
||||
def getresponse(self):
|
||||
"""Return our generated response from the request."""
|
||||
return self.http_response
|
||||
|
||||
|
||||
def wire_HTTPConnection_to_WSGI(host, app):
|
||||
"""Monkeypatches HTTPConnection so that if you try to connect to host, you
|
||||
are instead routed straight to the given WSGI app.
|
||||
|
||||
After calling this method, when any code calls
|
||||
|
||||
httplib.HTTPConnection(host)
|
||||
|
||||
the connection object will be a fake. Its requests will be sent directly
|
||||
to the given WSGI app rather than through a socket.
|
||||
|
||||
Code connecting to hosts other than host will not be affected.
|
||||
|
||||
This method may be called multiple times to map different hosts to
|
||||
different apps.
|
||||
|
||||
This method returns the original HTTPConnection object, so that the caller
|
||||
can restore the default HTTPConnection interface (for all hosts).
|
||||
"""
|
||||
class HTTPConnectionDecorator(object):
|
||||
"""Wraps the real HTTPConnection class so that when you instantiate
|
||||
the class you might instead get a fake instance.
|
||||
"""
|
||||
|
||||
def __init__(self, wrapped):
|
||||
self.wrapped = wrapped
|
||||
|
||||
def __call__(self, connection_host, *args, **kwargs):
|
||||
if connection_host == host:
|
||||
return FakeHttplibConnection(app, host)
|
||||
else:
|
||||
return self.wrapped(connection_host, *args, **kwargs)
|
||||
|
||||
oldHTTPConnection = httplib.HTTPConnection
|
||||
httplib.HTTPConnection = HTTPConnectionDecorator(httplib.HTTPConnection)
|
||||
return oldHTTPConnection
|
||||
|
||||
|
||||
class WsgiLimiterProxyTest(BaseLimitTestSuite):
|
||||
"""
|
||||
Tests for the `limits.WsgiLimiterProxy` class.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Do some nifty HTTP/WSGI magic which allows for WSGI to be called
|
||||
directly by something like the `httplib` library.
|
||||
"""
|
||||
super(WsgiLimiterProxyTest, self).setUp()
|
||||
self.app = limits.WsgiLimiter(TEST_LIMITS)
|
||||
self.oldHTTPConnection = (
|
||||
wire_HTTPConnection_to_WSGI("169.254.0.1:80", self.app))
|
||||
self.proxy = limits.WsgiLimiterProxy("169.254.0.1:80")
|
||||
|
||||
def test_200(self):
|
||||
# Successful request test.
|
||||
delay = self.proxy.check_for_delay("GET", "/anything")
|
||||
self.assertEqual(delay, (None, None))
|
||||
|
||||
def test_403(self):
|
||||
# Forbidden request test.
|
||||
delay = self.proxy.check_for_delay("GET", "/delayed")
|
||||
self.assertEqual(delay, (None, None))
|
||||
|
||||
delay, error = self.proxy.check_for_delay("GET", "/delayed")
|
||||
error = error.strip()
|
||||