Merge lp:~blamar/nova/lp728587 into lp:~hudson-openstack/nova/trunk
- lp728587
- Merge into trunk
Status: | Merged | ||||||||
---|---|---|---|---|---|---|---|---|---|
Approved by: | Rick Harris | ||||||||
Approved revision: | 806 | ||||||||
Merged at revision: | 830 | ||||||||
Proposed branch: | lp:~blamar/nova/lp728587 | ||||||||
Merge into: | lp:~hudson-openstack/nova/trunk | ||||||||
Diff against target: |
1238 lines (+912/-169) 8 files modified
etc/api-paste.ini (+1/-1) nova/api/openstack/__init__.py (+6/-0) nova/api/openstack/faults.py (+39/-0) nova/api/openstack/limits.py (+358/-0) nova/tests/api/openstack/__init__.py (+1/-1) nova/tests/api/openstack/fakes.py (+5/-5) nova/tests/api/openstack/test_adminapi.py (+0/-1) nova/tests/api/openstack/test_limits.py (+502/-161) |
||||||||
To merge this branch: | bzr merge lp:~blamar/nova/lp728587 | ||||||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Rick Harris (community) | Approve | ||
Paul Voccio (community) | Approve | ||
Titan | Pending | ||
Mark Washenberger | Pending | ||
Review via email: mp+53554@code.launchpad.net |
Commit message
Re-implementation (or just implementation in many cases) of Limits in the OpenStack API. Limits is now available through /limits and the concept of a limit has been extended to include arbitrary regex / http verb combinations along with correct XML/JSON serialization. Tests included.
Description of the change
The addition of a /limits resource was a larger job than I had first thought. The original rate-limiting middleware and supporting classes were being tested through `time.sleep` calls instead of actually unit-testing the logic.
I've replaced most of the classes with something that is more testable and much more compatible with the output we're generating for the 1.1 API.
Overall we should have much more test coverage than with the previous limits implementation.
- 800. By Brian Lamar
-
Removed VIM specific stuff and changed copyright from 2010 to 2011.
- 801. By Brian Lamar
-
Added tests back for RateLimitingMid
dleware which now throw correctly serialized
errors with correct error codes.Removed some error printing, and simplified some other parts of the code with
suggestions from teammates. - 802. By Brian Lamar
-
Added i18n to error message.
Jay Pipes (jaypipes) wrote : | # |
Jay Pipes (jaypipes) wrote : | # |
Also, if you don't select *any* reviewer, it defaults to nova-core, just an FYI...
Brian Lamar (blamar) wrote : | # |
Hey Jay, I was waiting to hear back from a teammate before submitting directly to nova-core. I'll add them now since I realize the deadline is coming up soonish! Thanks again.
- 803. By Brian Lamar
-
Merged trunk.
Paul Voccio (pvo) wrote : | # |
nova/api/
nova/api/
got a few pep8 nits to go over. Will look for the remerge.
- 804. By Brian Lamar
-
Fixed pep8 violation.
Paul Voccio (pvo) wrote : | # |
upgraded my pep8 to 0.6.1 and everything looked clean.
- 805. By Brian Lamar
-
Pep8 error, oddly specific to pep8 v0.5 < x > v0.6
Rick Harris (rconradharris) wrote : | # |
Great work, Brian.
Some femto-nits:
> 245 + self.remaining = math.floor((cap - water) / cap * val)
This might benefit from one more paren just to make it abundantly clear what's going on:
self.remaining = math.floor(((cap - water) / cap) * val)
> 901 + return sum(filter(lambda x: x != None, results))
Might be clearer as:
return sum(result for result in results if result)
As I understand it, the recommendation is to favor list-comprehensions rather than filter/map/reduce where possible.
Brian Lamar (blamar) wrote : | # |
Thanks Rick. I hate to see the day that people don't quite get order of operations, but it's a great femto-nit (I'd even accept it as a pico-nit!).
As for the sum() line, I'd like to say your solution is more clear...but it is at least more efficient. I must not have thought about that line too long because now that I look at it:
---
return sum(filter(lambda x: x != None, results))
is the same as
return sum(filter(None, results))
---
That being said I understand list comprehensions (or actually in this case I think it ends up being a generator comprehension, which I just learned about!) are preferred so I've gone ahead and made these changes.
Thanks a lot for the review!
- 806. By Brian Lamar
-
Better comment for fault. Improved readability of two small sections.
Rick Harris (rconradharris) wrote : | # |
lgtm, thanks for the fixes.
Preview Diff
1 | === modified file 'etc/api-paste.ini' | |||
2 | --- etc/api-paste.ini 2011-03-07 19:33:24 +0000 | |||
3 | +++ etc/api-paste.ini 2011-03-17 20:28:35 +0000 | |||
4 | @@ -79,7 +79,7 @@ | |||
5 | 79 | paste.filter_factory = nova.api.openstack.auth:AuthMiddleware.factory | 79 | paste.filter_factory = nova.api.openstack.auth:AuthMiddleware.factory |
6 | 80 | 80 | ||
7 | 81 | [filter:ratelimit] | 81 | [filter:ratelimit] |
9 | 82 | paste.filter_factory = nova.api.openstack.ratelimiting:RateLimitingMiddleware.factory | 82 | paste.filter_factory = nova.api.openstack.limits:RateLimitingMiddleware.factory |
10 | 83 | 83 | ||
11 | 84 | [app:osapiapp] | 84 | [app:osapiapp] |
12 | 85 | paste.app_factory = nova.api.openstack:APIRouter.factory | 85 | paste.app_factory = nova.api.openstack:APIRouter.factory |
13 | 86 | 86 | ||
14 | === modified file 'nova/api/openstack/__init__.py' | |||
15 | --- nova/api/openstack/__init__.py 2011-03-11 19:49:32 +0000 | |||
16 | +++ nova/api/openstack/__init__.py 2011-03-17 20:28:35 +0000 | |||
17 | @@ -33,6 +33,7 @@ | |||
18 | 33 | from nova.api.openstack import consoles | 33 | from nova.api.openstack import consoles |
19 | 34 | from nova.api.openstack import flavors | 34 | from nova.api.openstack import flavors |
20 | 35 | from nova.api.openstack import images | 35 | from nova.api.openstack import images |
21 | 36 | from nova.api.openstack import limits | ||
22 | 36 | from nova.api.openstack import servers | 37 | from nova.api.openstack import servers |
23 | 37 | from nova.api.openstack import shared_ip_groups | 38 | from nova.api.openstack import shared_ip_groups |
24 | 38 | from nova.api.openstack import users | 39 | from nova.api.openstack import users |
25 | @@ -114,12 +115,17 @@ | |||
26 | 114 | 115 | ||
27 | 115 | mapper.resource("image", "images", controller=images.Controller(), | 116 | mapper.resource("image", "images", controller=images.Controller(), |
28 | 116 | collection={'detail': 'GET'}) | 117 | collection={'detail': 'GET'}) |
29 | 118 | |||
30 | 117 | mapper.resource("flavor", "flavors", controller=flavors.Controller(), | 119 | mapper.resource("flavor", "flavors", controller=flavors.Controller(), |
31 | 118 | collection={'detail': 'GET'}) | 120 | collection={'detail': 'GET'}) |
32 | 121 | |||
33 | 119 | mapper.resource("shared_ip_group", "shared_ip_groups", | 122 | mapper.resource("shared_ip_group", "shared_ip_groups", |
34 | 120 | collection={'detail': 'GET'}, | 123 | collection={'detail': 'GET'}, |
35 | 121 | controller=shared_ip_groups.Controller()) | 124 | controller=shared_ip_groups.Controller()) |
36 | 122 | 125 | ||
37 | 126 | _limits = limits.LimitsController() | ||
38 | 127 | mapper.resource("limit", "limits", controller=_limits) | ||
39 | 128 | |||
40 | 123 | super(APIRouter, self).__init__(mapper) | 129 | super(APIRouter, self).__init__(mapper) |
41 | 124 | 130 | ||
42 | 125 | 131 | ||
43 | 126 | 132 | ||
44 | === modified file 'nova/api/openstack/faults.py' | |||
45 | --- nova/api/openstack/faults.py 2011-03-09 20:08:11 +0000 | |||
46 | +++ nova/api/openstack/faults.py 2011-03-17 20:28:35 +0000 | |||
47 | @@ -61,3 +61,42 @@ | |||
48 | 61 | content_type = req.best_match_content_type() | 61 | content_type = req.best_match_content_type() |
49 | 62 | self.wrapped_exc.body = serializer.serialize(fault_data, content_type) | 62 | self.wrapped_exc.body = serializer.serialize(fault_data, content_type) |
50 | 63 | return self.wrapped_exc | 63 | return self.wrapped_exc |
51 | 64 | |||
52 | 65 | |||
53 | 66 | class OverLimitFault(webob.exc.HTTPException): | ||
54 | 67 | """ | ||
55 | 68 | Rate-limited request response. | ||
56 | 69 | """ | ||
57 | 70 | |||
58 | 71 | _serialization_metadata = { | ||
59 | 72 | "application/xml": { | ||
60 | 73 | "attributes": { | ||
61 | 74 | "overLimitFault": "code", | ||
62 | 75 | }, | ||
63 | 76 | }, | ||
64 | 77 | } | ||
65 | 78 | |||
66 | 79 | def __init__(self, message, details, retry_time): | ||
67 | 80 | """ | ||
68 | 81 | Initialize new `OverLimitFault` with relevant information. | ||
69 | 82 | """ | ||
70 | 83 | self.wrapped_exc = webob.exc.HTTPForbidden() | ||
71 | 84 | self.content = { | ||
72 | 85 | "overLimitFault": { | ||
73 | 86 | "code": self.wrapped_exc.status_int, | ||
74 | 87 | "message": message, | ||
75 | 88 | "details": details, | ||
76 | 89 | }, | ||
77 | 90 | } | ||
78 | 91 | |||
79 | 92 | @webob.dec.wsgify(RequestClass=wsgi.Request) | ||
80 | 93 | def __call__(self, request): | ||
81 | 94 | """ | ||
82 | 95 | Return the wrapped exception with a serialized body conforming to our | ||
83 | 96 | error format. | ||
84 | 97 | """ | ||
85 | 98 | serializer = wsgi.Serializer(self._serialization_metadata) | ||
86 | 99 | content_type = request.best_match_content_type() | ||
87 | 100 | content = serializer.serialize(self.content, content_type) | ||
88 | 101 | self.wrapped_exc.body = content | ||
89 | 102 | return self.wrapped_exc | ||
90 | 64 | 103 | ||
91 | === added file 'nova/api/openstack/limits.py' | |||
92 | --- nova/api/openstack/limits.py 1970-01-01 00:00:00 +0000 | |||
93 | +++ nova/api/openstack/limits.py 2011-03-17 20:28:35 +0000 | |||
94 | @@ -0,0 +1,358 @@ | |||
95 | 1 | # Copyright 2011 OpenStack LLC. | ||
96 | 2 | # All Rights Reserved. | ||
97 | 3 | # | ||
98 | 4 | # Licensed under the Apache License, Version 2.0 (the "License"); you may | ||
99 | 5 | # not use this file except in compliance with the License. You may obtain | ||
100 | 6 | # a copy of the License at | ||
101 | 7 | # | ||
102 | 8 | # http://www.apache.org/licenses/LICENSE-2.0 | ||
103 | 9 | # | ||
104 | 10 | # Unless required by applicable law or agreed to in writing, software | ||
105 | 11 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||
106 | 12 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||
107 | 13 | # License for the specific language governing permissions and limitations | ||
108 | 14 | # under the License.import datetime | ||
109 | 15 | |||
110 | 16 | """ | ||
111 | 17 | Module dedicated functions/classes dealing with rate limiting requests. | ||
112 | 18 | """ | ||
113 | 19 | |||
114 | 20 | import copy | ||
115 | 21 | import httplib | ||
116 | 22 | import json | ||
117 | 23 | import math | ||
118 | 24 | import re | ||
119 | 25 | import time | ||
120 | 26 | import urllib | ||
121 | 27 | import webob.exc | ||
122 | 28 | |||
123 | 29 | from collections import defaultdict | ||
124 | 30 | |||
125 | 31 | from webob.dec import wsgify | ||
126 | 32 | |||
127 | 33 | from nova import wsgi | ||
128 | 34 | from nova.api.openstack import faults | ||
129 | 35 | from nova.wsgi import Controller | ||
130 | 36 | from nova.wsgi import Middleware | ||
131 | 37 | |||
132 | 38 | |||
133 | 39 | # Convenience constants for the limits dictionary passed to Limiter(). | ||
134 | 40 | PER_SECOND = 1 | ||
135 | 41 | PER_MINUTE = 60 | ||
136 | 42 | PER_HOUR = 60 * 60 | ||
137 | 43 | PER_DAY = 60 * 60 * 24 | ||
138 | 44 | |||
139 | 45 | |||
140 | 46 | class LimitsController(Controller): | ||
141 | 47 | """ | ||
142 | 48 | Controller for accessing limits in the OpenStack API. | ||
143 | 49 | """ | ||
144 | 50 | |||
145 | 51 | _serialization_metadata = { | ||
146 | 52 | "application/xml": { | ||
147 | 53 | "attributes": { | ||
148 | 54 | "limit": ["verb", "URI", "regex", "value", "unit", | ||
149 | 55 | "resetTime", "remaining", "name"], | ||
150 | 56 | }, | ||
151 | 57 | "plurals": { | ||
152 | 58 | "rate": "limit", | ||
153 | 59 | }, | ||
154 | 60 | }, | ||
155 | 61 | } | ||
156 | 62 | |||
157 | 63 | def index(self, req): | ||
158 | 64 | """ | ||
159 | 65 | Return all global and rate limit information. | ||
160 | 66 | """ | ||
161 | 67 | abs_limits = {} | ||
162 | 68 | rate_limits = req.environ.get("nova.limits", []) | ||
163 | 69 | |||
164 | 70 | return { | ||
165 | 71 | "limits": { | ||
166 | 72 | "rate": rate_limits, | ||
167 | 73 | "absolute": abs_limits, | ||
168 | 74 | }, | ||
169 | 75 | } | ||
170 | 76 | |||
171 | 77 | |||
172 | 78 | class Limit(object): | ||
173 | 79 | """ | ||
174 | 80 | Stores information about a limit for HTTP requets. | ||
175 | 81 | """ | ||
176 | 82 | |||
177 | 83 | UNITS = { | ||
178 | 84 | 1: "SECOND", | ||
179 | 85 | 60: "MINUTE", | ||
180 | 86 | 60 * 60: "HOUR", | ||
181 | 87 | 60 * 60 * 24: "DAY", | ||
182 | 88 | } | ||
183 | 89 | |||
184 | 90 | def __init__(self, verb, uri, regex, value, unit): | ||
185 | 91 | """ | ||
186 | 92 | Initialize a new `Limit`. | ||
187 | 93 | |||
188 | 94 | @param verb: HTTP verb (POST, PUT, etc.) | ||
189 | 95 | @param uri: Human-readable URI | ||
190 | 96 | @param regex: Regular expression format for this limit | ||
191 | 97 | @param value: Integer number of requests which can be made | ||
192 | 98 | @param unit: Unit of measure for the value parameter | ||
193 | 99 | """ | ||
194 | 100 | self.verb = verb | ||
195 | 101 | self.uri = uri | ||
196 | 102 | self.regex = regex | ||
197 | 103 | self.value = int(value) | ||
198 | 104 | self.unit = unit | ||
199 | 105 | self.unit_string = self.display_unit().lower() | ||
200 | 106 | self.remaining = int(value) | ||
201 | 107 | |||
202 | 108 | if value <= 0: | ||
203 | 109 | raise ValueError("Limit value must be > 0") | ||
204 | 110 | |||
205 | 111 | self.last_request = None | ||
206 | 112 | self.next_request = None | ||
207 | 113 | |||
208 | 114 | self.water_level = 0 | ||
209 | 115 | self.capacity = self.unit | ||
210 | 116 | self.request_value = float(self.capacity) / float(self.value) | ||
211 | 117 | self.error_message = _("Only %(value)s %(verb)s request(s) can be "\ | ||
212 | 118 | "made to %(uri)s every %(unit_string)s." % self.__dict__) | ||
213 | 119 | |||
214 | 120 | def __call__(self, verb, url): | ||
215 | 121 | """ | ||
216 | 122 | Represents a call to this limit from a relevant request. | ||
217 | 123 | |||
218 | 124 | @param verb: string http verb (POST, GET, etc.) | ||
219 | 125 | @param url: string URL | ||
220 | 126 | """ | ||
221 | 127 | if self.verb != verb or not re.match(self.regex, url): | ||
222 | 128 | return | ||
223 | 129 | |||
224 | 130 | now = self._get_time() | ||
225 | 131 | |||
226 | 132 | if self.last_request is None: | ||
227 | 133 | self.last_request = now | ||
228 | 134 | |||
229 | 135 | leak_value = now - self.last_request | ||
230 | 136 | |||
231 | 137 | self.water_level -= leak_value | ||
232 | 138 | self.water_level = max(self.water_level, 0) | ||
233 | 139 | self.water_level += self.request_value | ||
234 | 140 | |||
235 | 141 | difference = self.water_level - self.capacity | ||
236 | 142 | |||
237 | 143 | self.last_request = now | ||
238 | 144 | |||
239 | 145 | if difference > 0: | ||
240 | 146 | self.water_level -= self.request_value | ||
241 | 147 | self.next_request = now + difference | ||
242 | 148 | return difference | ||
243 | 149 | |||
244 | 150 | cap = self.capacity | ||
245 | 151 | water = self.water_level | ||
246 | 152 | val = self.value | ||
247 | 153 | |||
248 | 154 | self.remaining = math.floor(((cap - water) / cap) * val) | ||
249 | 155 | self.next_request = now | ||
250 | 156 | |||
251 | 157 | def _get_time(self): | ||
252 | 158 | """Retrieve the current time. Broken out for testability.""" | ||
253 | 159 | return time.time() | ||
254 | 160 | |||
255 | 161 | def display_unit(self): | ||
256 | 162 | """Display the string name of the unit.""" | ||
257 | 163 | return self.UNITS.get(self.unit, "UNKNOWN") | ||
258 | 164 | |||
259 | 165 | def display(self): | ||
260 | 166 | """Return a useful representation of this class.""" | ||
261 | 167 | return { | ||
262 | 168 | "verb": self.verb, | ||
263 | 169 | "URI": self.uri, | ||
264 | 170 | "regex": self.regex, | ||
265 | 171 | "value": self.value, | ||
266 | 172 | "remaining": int(self.remaining), | ||
267 | 173 | "unit": self.display_unit(), | ||
268 | 174 | "resetTime": int(self.next_request or self._get_time()), | ||
269 | 175 | } | ||
270 | 176 | |||
271 | 177 | # "Limit" format is a dictionary with the HTTP verb, human-readable URI, | ||
272 | 178 | # a regular-expression to match, value and unit of measure (PER_DAY, etc.) | ||
273 | 179 | |||
274 | 180 | DEFAULT_LIMITS = [ | ||
275 | 181 | Limit("POST", "*", ".*", 10, PER_MINUTE), | ||
276 | 182 | Limit("POST", "*/servers", "^/servers", 50, PER_DAY), | ||
277 | 183 | Limit("PUT", "*", ".*", 10, PER_MINUTE), | ||
278 | 184 | Limit("GET", "*changes-since*", ".*changes-since.*", 3, PER_MINUTE), | ||
279 | 185 | Limit("DELETE", "*", ".*", 100, PER_MINUTE), | ||
280 | 186 | ] | ||
281 | 187 | |||
282 | 188 | |||
283 | 189 | class RateLimitingMiddleware(Middleware): | ||
284 | 190 | """ | ||
285 | 191 | Rate-limits requests passing through this middleware. All limit information | ||
286 | 192 | is stored in memory for this implementation. | ||
287 | 193 | """ | ||
288 | 194 | |||
289 | 195 | def __init__(self, application, limits=None): | ||
290 | 196 | """ | ||
291 | 197 | Initialize new `RateLimitingMiddleware`, which wraps the given WSGI | ||
292 | 198 | application and sets up the given limits. | ||
293 | 199 | |||
294 | 200 | @param application: WSGI application to wrap | ||
295 | 201 | @param limits: List of dictionaries describing limits | ||
296 | 202 | """ | ||
297 | 203 | Middleware.__init__(self, application) | ||
298 | 204 | self._limiter = Limiter(limits or DEFAULT_LIMITS) | ||
299 | 205 | |||
300 | 206 | @wsgify(RequestClass=wsgi.Request) | ||
301 | 207 | def __call__(self, req): | ||
302 | 208 | """ | ||
303 | 209 | Represents a single call through this middleware. We should record the | ||
304 | 210 | request if we have a limit relevant to it. If no limit is relevant to | ||
305 | 211 | the request, ignore it. | ||
306 | 212 | |||
307 | 213 | If the request should be rate limited, return a fault telling the user | ||
308 | 214 | they are over the limit and need to retry later. | ||
309 | 215 | """ | ||
310 | 216 | verb = req.method | ||
311 | 217 | url = req.url | ||
312 | 218 | context = req.environ.get("nova.context") | ||
313 | 219 | |||
314 | 220 | if context: | ||
315 | 221 | username = context.user_id | ||
316 | 222 | else: | ||
317 | 223 | username = None | ||
318 | 224 | |||
319 | 225 | delay, error = self._limiter.check_for_delay(verb, url, username) | ||
320 | 226 | |||
321 | 227 | if delay: | ||
322 | 228 | msg = _("This request was rate-limited.") | ||
323 | 229 | retry = time.time() + delay | ||
324 | 230 | return faults.OverLimitFault(msg, error, retry) | ||
325 | 231 | |||
326 | 232 | req.environ["nova.limits"] = self._limiter.get_limits(username) | ||
327 | 233 | |||
328 | 234 | return self.application | ||
329 | 235 | |||
330 | 236 | |||
331 | 237 | class Limiter(object): | ||
332 | 238 | """ | ||
333 | 239 | Rate-limit checking class which handles limits in memory. | ||
334 | 240 | """ | ||
335 | 241 | |||
336 | 242 | def __init__(self, limits): | ||
337 | 243 | """ | ||
338 | 244 | Initialize the new `Limiter`. | ||
339 | 245 | |||
340 | 246 | @param limits: List of `Limit` objects | ||
341 | 247 | """ | ||
342 | 248 | self.limits = copy.deepcopy(limits) | ||
343 | 249 | self.levels = defaultdict(lambda: copy.deepcopy(limits)) | ||
344 | 250 | |||
345 | 251 | def get_limits(self, username=None): | ||
346 | 252 | """ | ||
347 | 253 | Return the limits for a given user. | ||
348 | 254 | """ | ||
349 | 255 | return [limit.display() for limit in self.levels[username]] | ||
350 | 256 | |||
351 | 257 | def check_for_delay(self, verb, url, username=None): | ||
352 | 258 | """ | ||
353 | 259 | Check the given verb/user/user triplet for limit. | ||
354 | 260 | |||
355 | 261 | @return: Tuple of delay (in seconds) and error message (or None, None) | ||
356 | 262 | """ | ||
357 | 263 | delays = [] | ||
358 | 264 | |||
359 | 265 | for limit in self.levels[username]: | ||
360 | 266 | delay = limit(verb, url) | ||
361 | 267 | if delay: | ||
362 | 268 | delays.append((delay, limit.error_message)) | ||
363 | 269 | |||
364 | 270 | if delays: | ||
365 | 271 | delays.sort() | ||
366 | 272 | return delays[0] | ||
367 | 273 | |||
368 | 274 | return None, None | ||
369 | 275 | |||
370 | 276 | |||
371 | 277 | class WsgiLimiter(object): | ||
372 | 278 | """ | ||
373 | 279 | Rate-limit checking from a WSGI application. Uses an in-memory `Limiter`. | ||
374 | 280 | |||
375 | 281 | To use: | ||
376 | 282 | POST /<username> with JSON data such as: | ||
377 | 283 | { | ||
378 | 284 | "verb" : GET, | ||
379 | 285 | "path" : "/servers" | ||
380 | 286 | } | ||
381 | 287 | |||
382 | 288 | and receive a 204 No Content, or a 403 Forbidden with an X-Wait-Seconds | ||
383 | 289 | header containing the number of seconds to wait before the action would | ||
384 | 290 | succeed. | ||
385 | 291 | """ | ||
386 | 292 | |||
387 | 293 | def __init__(self, limits=None): | ||
388 | 294 | """ | ||
389 | 295 | Initialize the new `WsgiLimiter`. | ||
390 | 296 | |||
391 | 297 | @param limits: List of `Limit` objects | ||
392 | 298 | """ | ||
393 | 299 | self._limiter = Limiter(limits or DEFAULT_LIMITS) | ||
394 | 300 | |||
395 | 301 | @wsgify(RequestClass=wsgi.Request) | ||
396 | 302 | def __call__(self, request): | ||
397 | 303 | """ | ||
398 | 304 | Handles a call to this application. Returns 204 if the request is | ||
399 | 305 | acceptable to the limiter, else a 403 is returned with a relevant | ||
400 | 306 | header indicating when the request *will* succeed. | ||
401 | 307 | """ | ||
402 | 308 | if request.method != "POST": | ||
403 | 309 | raise webob.exc.HTTPMethodNotAllowed() | ||
404 | 310 | |||
405 | 311 | try: | ||
406 | 312 | info = dict(json.loads(request.body)) | ||
407 | 313 | except ValueError: | ||
408 | 314 | raise webob.exc.HTTPBadRequest() | ||
409 | 315 | |||
410 | 316 | username = request.path_info_pop() | ||
411 | 317 | verb = info.get("verb") | ||
412 | 318 | path = info.get("path") | ||
413 | 319 | |||
414 | 320 | delay, error = self._limiter.check_for_delay(verb, path, username) | ||
415 | 321 | |||
416 | 322 | if delay: | ||
417 | 323 | headers = {"X-Wait-Seconds": "%.2f" % delay} | ||
418 | 324 | return webob.exc.HTTPForbidden(headers=headers, explanation=error) | ||
419 | 325 | else: | ||
420 | 326 | return webob.exc.HTTPNoContent() | ||
421 | 327 | |||
422 | 328 | |||
423 | 329 | class WsgiLimiterProxy(object): | ||
424 | 330 | """ | ||
425 | 331 | Rate-limit requests based on answers from a remote source. | ||
426 | 332 | """ | ||
427 | 333 | |||
428 | 334 | def __init__(self, limiter_address): | ||
429 | 335 | """ | ||
430 | 336 | Initialize the new `WsgiLimiterProxy`. | ||
431 | 337 | |||
432 | 338 | @param limiter_address: IP/port combination of where to request limit | ||
433 | 339 | """ | ||
434 | 340 | self.limiter_address = limiter_address | ||
435 | 341 | |||
436 | 342 | def check_for_delay(self, verb, path, username=None): | ||
437 | 343 | body = json.dumps({"verb": verb, "path": path}) | ||
438 | 344 | headers = {"Content-Type": "application/json"} | ||
439 | 345 | |||
440 | 346 | conn = httplib.HTTPConnection(self.limiter_address) | ||
441 | 347 | |||
442 | 348 | if username: | ||
443 | 349 | conn.request("POST", "/%s" % (username), body, headers) | ||
444 | 350 | else: | ||
445 | 351 | conn.request("POST", "/", body, headers) | ||
446 | 352 | |||
447 | 353 | resp = conn.getresponse() | ||
448 | 354 | |||
449 | 355 | if 200 >= resp.status < 300: | ||
450 | 356 | return None, None | ||
451 | 357 | |||
452 | 358 | return resp.getheader("X-Wait-Seconds"), resp.read() or None | ||
453 | 0 | 359 | ||
454 | === modified file 'nova/tests/api/openstack/__init__.py' | |||
455 | --- nova/tests/api/openstack/__init__.py 2011-02-23 19:56:37 +0000 | |||
456 | +++ nova/tests/api/openstack/__init__.py 2011-03-17 20:28:35 +0000 | |||
457 | @@ -20,7 +20,7 @@ | |||
458 | 20 | 20 | ||
459 | 21 | from nova import context | 21 | from nova import context |
460 | 22 | from nova import flags | 22 | from nova import flags |
462 | 23 | from nova.api.openstack.ratelimiting import RateLimitingMiddleware | 23 | from nova.api.openstack.limits import RateLimitingMiddleware |
463 | 24 | from nova.api.openstack.common import limited | 24 | from nova.api.openstack.common import limited |
464 | 25 | from nova.tests.api.openstack import fakes | 25 | from nova.tests.api.openstack import fakes |
465 | 26 | from webob import Request | 26 | from webob import Request |
466 | 27 | 27 | ||
467 | === modified file 'nova/tests/api/openstack/fakes.py' | |||
468 | --- nova/tests/api/openstack/fakes.py 2011-03-16 19:16:16 +0000 | |||
469 | +++ nova/tests/api/openstack/fakes.py 2011-03-17 20:28:35 +0000 | |||
470 | @@ -34,7 +34,7 @@ | |||
471 | 34 | import nova.api.openstack.auth | 34 | import nova.api.openstack.auth |
472 | 35 | from nova.api import openstack | 35 | from nova.api import openstack |
473 | 36 | from nova.api.openstack import auth | 36 | from nova.api.openstack import auth |
475 | 37 | from nova.api.openstack import ratelimiting | 37 | from nova.api.openstack import limits |
476 | 38 | from nova.auth.manager import User, Project | 38 | from nova.auth.manager import User, Project |
477 | 39 | from nova.image import glance | 39 | from nova.image import glance |
478 | 40 | from nova.image import local | 40 | from nova.image import local |
479 | @@ -77,7 +77,7 @@ | |||
480 | 77 | inner_application = openstack.APIRouter() | 77 | inner_application = openstack.APIRouter() |
481 | 78 | mapper = urlmap.URLMap() | 78 | mapper = urlmap.URLMap() |
482 | 79 | api = openstack.FaultWrapper(auth.AuthMiddleware( | 79 | api = openstack.FaultWrapper(auth.AuthMiddleware( |
484 | 80 | ratelimiting.RateLimitingMiddleware(inner_application))) | 80 | limits.RateLimitingMiddleware(inner_application))) |
485 | 81 | mapper['/v1.0'] = api | 81 | mapper['/v1.0'] = api |
486 | 82 | mapper['/'] = openstack.FaultWrapper(openstack.Versions()) | 82 | mapper['/'] = openstack.FaultWrapper(openstack.Versions()) |
487 | 83 | return mapper | 83 | return mapper |
488 | @@ -115,13 +115,13 @@ | |||
489 | 115 | 115 | ||
490 | 116 | def stub_out_rate_limiting(stubs): | 116 | def stub_out_rate_limiting(stubs): |
491 | 117 | def fake_rate_init(self, app): | 117 | def fake_rate_init(self, app): |
493 | 118 | super(ratelimiting.RateLimitingMiddleware, self).__init__(app) | 118 | super(limits.RateLimitingMiddleware, self).__init__(app) |
494 | 119 | self.application = app | 119 | self.application = app |
495 | 120 | 120 | ||
497 | 121 | stubs.Set(nova.api.openstack.ratelimiting.RateLimitingMiddleware, | 121 | stubs.Set(nova.api.openstack.limits.RateLimitingMiddleware, |
498 | 122 | '__init__', fake_rate_init) | 122 | '__init__', fake_rate_init) |
499 | 123 | 123 | ||
501 | 124 | stubs.Set(nova.api.openstack.ratelimiting.RateLimitingMiddleware, | 124 | stubs.Set(nova.api.openstack.limits.RateLimitingMiddleware, |
502 | 125 | '__call__', fake_wsgi) | 125 | '__call__', fake_wsgi) |
503 | 126 | 126 | ||
504 | 127 | 127 | ||
505 | 128 | 128 | ||
506 | === modified file 'nova/tests/api/openstack/test_adminapi.py' | |||
507 | --- nova/tests/api/openstack/test_adminapi.py 2011-03-08 17:18:13 +0000 | |||
508 | +++ nova/tests/api/openstack/test_adminapi.py 2011-03-17 20:28:35 +0000 | |||
509 | @@ -23,7 +23,6 @@ | |||
510 | 23 | from nova import flags | 23 | from nova import flags |
511 | 24 | from nova import test | 24 | from nova import test |
512 | 25 | from nova.api import openstack | 25 | from nova.api import openstack |
513 | 26 | from nova.api.openstack import ratelimiting | ||
514 | 27 | from nova.api.openstack import auth | 26 | from nova.api.openstack import auth |
515 | 28 | from nova.tests.api.openstack import fakes | 27 | from nova.tests.api.openstack import fakes |
516 | 29 | 28 | ||
517 | 30 | 29 | ||
518 | === renamed file 'nova/tests/api/openstack/test_ratelimiting.py' => 'nova/tests/api/openstack/test_limits.py' | |||
519 | --- nova/tests/api/openstack/test_ratelimiting.py 2011-02-23 19:56:37 +0000 | |||
520 | +++ nova/tests/api/openstack/test_limits.py 2011-03-17 20:28:35 +0000 | |||
521 | @@ -1,178 +1,520 @@ | |||
522 | 1 | # Copyright 2011 OpenStack LLC. | ||
523 | 2 | # All Rights Reserved. | ||
524 | 3 | # | ||
525 | 4 | # Licensed under the Apache License, Version 2.0 (the "License"); you may | ||
526 | 5 | # not use this file except in compliance with the License. You may obtain | ||
527 | 6 | # a copy of the License at | ||
528 | 7 | # | ||
529 | 8 | # http://www.apache.org/licenses/LICENSE-2.0 | ||
530 | 9 | # | ||
531 | 10 | # Unless required by applicable law or agreed to in writing, software | ||
532 | 11 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||
533 | 12 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||
534 | 13 | # License for the specific language governing permissions and limitations | ||
535 | 14 | # under the License. | ||
536 | 15 | |||
537 | 16 | """ | ||
538 | 17 | Tests dealing with HTTP rate-limiting. | ||
539 | 18 | """ | ||
540 | 19 | |||
541 | 1 | import httplib | 20 | import httplib |
542 | 21 | import json | ||
543 | 2 | import StringIO | 22 | import StringIO |
544 | 23 | import stubout | ||
545 | 3 | import time | 24 | import time |
546 | 25 | import unittest | ||
547 | 4 | import webob | 26 | import webob |
548 | 5 | 27 | ||
656 | 6 | from nova import test | 28 | from xml.dom.minidom import parseString |
657 | 7 | import nova.api.openstack.ratelimiting as ratelimiting | 29 | |
658 | 8 | 30 | from nova.api.openstack import limits | |
659 | 9 | 31 | from nova.api.openstack.limits import Limit | |
660 | 10 | class LimiterTest(test.TestCase): | 32 | |
661 | 11 | 33 | ||
662 | 12 | def setUp(self): | 34 | TEST_LIMITS = [ |
663 | 13 | super(LimiterTest, self).setUp() | 35 | Limit("GET", "/delayed", "^/delayed", 1, limits.PER_MINUTE), |
664 | 14 | self.limits = { | 36 | Limit("POST", "*", ".*", 7, limits.PER_MINUTE), |
665 | 15 | 'a': (5, ratelimiting.PER_SECOND), | 37 | Limit("POST", "/servers", "^/servers", 3, limits.PER_MINUTE), |
666 | 16 | 'b': (5, ratelimiting.PER_MINUTE), | 38 | Limit("PUT", "*", "", 10, limits.PER_MINUTE), |
667 | 17 | 'c': (5, ratelimiting.PER_HOUR), | 39 | Limit("PUT", "/servers", "^/servers", 5, limits.PER_MINUTE), |
668 | 18 | 'd': (1, ratelimiting.PER_SECOND), | 40 | ] |
669 | 19 | 'e': (100, ratelimiting.PER_SECOND)} | 41 | |
670 | 20 | self.rl = ratelimiting.Limiter(self.limits) | 42 | |
671 | 21 | 43 | class BaseLimitTestSuite(unittest.TestCase): | |
672 | 22 | def exhaust(self, action, times_until_exhausted, **kwargs): | 44 | """Base test suite which provides relevant stubs and time abstraction.""" |
673 | 23 | for i in range(times_until_exhausted): | 45 | |
674 | 24 | when = self.rl.perform(action, **kwargs) | 46 | def setUp(self): |
675 | 25 | self.assertEqual(when, None) | 47 | """Run before each test.""" |
676 | 26 | num, period = self.limits[action] | 48 | self.time = 0.0 |
677 | 27 | delay = period * 1.0 / num | 49 | self.stubs = stubout.StubOutForTesting() |
678 | 28 | # Verify that we are now thoroughly delayed | 50 | self.stubs.Set(limits.Limit, "_get_time", self._get_time) |
679 | 29 | for i in range(10): | 51 | |
680 | 30 | when = self.rl.perform(action, **kwargs) | 52 | def tearDown(self): |
681 | 31 | self.assertAlmostEqual(when, delay, 2) | 53 | """Run after each test.""" |
682 | 32 | 54 | self.stubs.UnsetAll() | |
683 | 33 | def test_second(self): | 55 | |
684 | 34 | self.exhaust('a', 5) | 56 | def _get_time(self): |
685 | 35 | time.sleep(0.2) | 57 | """Return the "time" according to this test suite.""" |
686 | 36 | self.exhaust('a', 1) | 58 | return self.time |
687 | 37 | time.sleep(1) | 59 | |
688 | 38 | self.exhaust('a', 5) | 60 | |
689 | 39 | 61 | class LimitsControllerTest(BaseLimitTestSuite): | |
690 | 40 | def test_minute(self): | 62 | """ |
691 | 41 | self.exhaust('b', 5) | 63 | Tests for `limits.LimitsController` class. |
692 | 42 | 64 | """ | |
693 | 43 | def test_one_per_period(self): | 65 | |
694 | 44 | def allow_once_and_deny_once(): | 66 | def setUp(self): |
695 | 45 | when = self.rl.perform('d') | 67 | """Run before each test.""" |
696 | 46 | self.assertEqual(when, None) | 68 | BaseLimitTestSuite.setUp(self) |
697 | 47 | when = self.rl.perform('d') | 69 | self.controller = limits.LimitsController() |
698 | 48 | self.assertAlmostEqual(when, 1, 2) | 70 | |
699 | 49 | return when | 71 | def _get_index_request(self, accept_header="application/json"): |
700 | 50 | time.sleep(allow_once_and_deny_once()) | 72 | """Helper to set routing arguments.""" |
701 | 51 | time.sleep(allow_once_and_deny_once()) | 73 | request = webob.Request.blank("/") |
702 | 52 | allow_once_and_deny_once() | 74 | request.accept = accept_header |
703 | 53 | 75 | request.environ["wsgiorg.routing_args"] = (None, { | |
704 | 54 | def test_we_can_go_indefinitely_if_we_spread_out_requests(self): | 76 | "action": "index", |
705 | 55 | for i in range(200): | 77 | "controller": "", |
706 | 56 | when = self.rl.perform('e') | 78 | }) |
707 | 57 | self.assertEqual(when, None) | 79 | return request |
708 | 58 | time.sleep(0.01) | 80 | |
709 | 59 | 81 | def _populate_limits(self, request): | |
710 | 60 | def test_users_get_separate_buckets(self): | 82 | """Put limit info into a request.""" |
711 | 61 | self.exhaust('c', 5, username='alice') | 83 | _limits = [ |
712 | 62 | self.exhaust('c', 5, username='bob') | 84 | Limit("GET", "*", ".*", 10, 60).display(), |
713 | 63 | self.exhaust('c', 5, username='chuck') | 85 | Limit("POST", "*", ".*", 5, 60 * 60).display(), |
714 | 64 | self.exhaust('c', 0, username='chuck') | 86 | ] |
715 | 65 | self.exhaust('c', 0, username='bob') | 87 | request.environ["nova.limits"] = _limits |
716 | 66 | self.exhaust('c', 0, username='alice') | 88 | return request |
717 | 67 | 89 | ||
718 | 68 | 90 | def test_empty_index_json(self): | |
719 | 69 | class FakeLimiter(object): | 91 | """Test getting empty limit details in JSON.""" |
720 | 70 | """Fake Limiter class that you can tell how to behave.""" | 92 | request = self._get_index_request() |
721 | 71 | 93 | response = request.get_response(self.controller) | |
722 | 72 | def __init__(self, test): | 94 | expected = { |
723 | 73 | self._action = self._username = self._delay = None | 95 | "limits": { |
724 | 74 | self.test = test | 96 | "rate": [], |
725 | 75 | 97 | "absolute": {}, | |
726 | 76 | def mock(self, action, username, delay): | 98 | }, |
727 | 77 | self._action = action | 99 | } |
728 | 78 | self._username = username | 100 | body = json.loads(response.body) |
729 | 79 | self._delay = delay | 101 | self.assertEqual(expected, body) |
730 | 80 | 102 | ||
731 | 81 | def perform(self, action, username): | 103 | def test_index_json(self): |
732 | 82 | self.test.assertEqual(action, self._action) | 104 | """Test getting limit details in JSON.""" |
733 | 83 | self.test.assertEqual(username, self._username) | 105 | request = self._get_index_request() |
734 | 84 | return self._delay | 106 | request = self._populate_limits(request) |
735 | 85 | 107 | response = request.get_response(self.controller) | |
736 | 86 | 108 | expected = { | |
737 | 87 | class WSGIAppTest(test.TestCase): | 109 | "limits": { |
738 | 88 | 110 | "rate": [{ | |
739 | 89 | def setUp(self): | 111 | "regex": ".*", |
740 | 90 | super(WSGIAppTest, self).setUp() | 112 | "resetTime": 0, |
741 | 91 | self.limiter = FakeLimiter(self) | 113 | "URI": "*", |
742 | 92 | self.app = ratelimiting.WSGIApp(self.limiter) | 114 | "value": 10, |
743 | 93 | 115 | "verb": "GET", | |
744 | 94 | def test_invalid_methods(self): | 116 | "remaining": 10, |
745 | 95 | requests = [] | 117 | "unit": "MINUTE", |
746 | 96 | for method in ['GET', 'PUT', 'DELETE']: | 118 | }, |
747 | 97 | req = webob.Request.blank('/limits/michael/breakdance', | 119 | { |
748 | 98 | dict(REQUEST_METHOD=method)) | 120 | "regex": ".*", |
749 | 99 | requests.append(req) | 121 | "resetTime": 0, |
750 | 100 | for req in requests: | 122 | "URI": "*", |
751 | 101 | self.assertEqual(req.get_response(self.app).status_int, 405) | 123 | "value": 5, |
752 | 102 | 124 | "verb": "POST", | |
753 | 103 | def test_invalid_urls(self): | 125 | "remaining": 5, |
754 | 104 | requests = [] | 126 | "unit": "HOUR", |
755 | 105 | for prefix in ['limit', '', 'limiter2', 'limiter/limits', 'limiter/1']: | 127 | }], |
756 | 106 | req = webob.Request.blank('/%s/michael/breakdance' % prefix, | 128 | "absolute": {}, |
757 | 107 | dict(REQUEST_METHOD='POST')) | 129 | }, |
758 | 108 | requests.append(req) | 130 | } |
759 | 109 | for req in requests: | 131 | body = json.loads(response.body) |
760 | 110 | self.assertEqual(req.get_response(self.app).status_int, 404) | 132 | self.assertEqual(expected, body) |
761 | 111 | 133 | ||
762 | 112 | def verify(self, url, username, action, delay=None): | 134 | def test_empty_index_xml(self): |
763 | 135 | """Test getting limit details in XML.""" | ||
764 | 136 | request = self._get_index_request("application/xml") | ||
765 | 137 | response = request.get_response(self.controller) | ||
766 | 138 | |||
767 | 139 | expected = "<limits><rate/><absolute/></limits>" | ||
768 | 140 | body = response.body.replace("\n", "").replace(" ", "") | ||
769 | 141 | |||
770 | 142 | self.assertEqual(expected, body) | ||
771 | 143 | |||
772 | 144 | def test_index_xml(self): | ||
773 | 145 | """Test getting limit details in XML.""" | ||
774 | 146 | request = self._get_index_request("application/xml") | ||
775 | 147 | request = self._populate_limits(request) | ||
776 | 148 | response = request.get_response(self.controller) | ||
777 | 149 | |||
778 | 150 | expected = parseString(""" | ||
779 | 151 | <limits> | ||
780 | 152 | <rate> | ||
781 | 153 | <limit URI="*" regex=".*" remaining="10" resetTime="0" | ||
782 | 154 | unit="MINUTE" value="10" verb="GET"/> | ||
783 | 155 | <limit URI="*" regex=".*" remaining="5" resetTime="0" | ||
784 | 156 | unit="HOUR" value="5" verb="POST"/> | ||
785 | 157 | </rate> | ||
786 | 158 | <absolute/> | ||
787 | 159 | </limits> | ||
788 | 160 | """.replace(" ", "")) | ||
789 | 161 | body = parseString(response.body.replace(" ", "")) | ||
790 | 162 | |||
791 | 163 | self.assertEqual(expected.toxml(), body.toxml()) | ||
792 | 164 | |||
793 | 165 | |||
794 | 166 | class LimitMiddlewareTest(BaseLimitTestSuite): | ||
795 | 167 | """ | ||
796 | 168 | Tests for the `limits.RateLimitingMiddleware` class. | ||
797 | 169 | """ | ||
798 | 170 | |||
799 | 171 | @webob.dec.wsgify | ||
800 | 172 | def _empty_app(self, request): | ||
801 | 173 | """Do-nothing WSGI app.""" | ||
802 | 174 | pass | ||
803 | 175 | |||
804 | 176 | def setUp(self): | ||
805 | 177 | """Prepare middleware for use through fake WSGI app.""" | ||
806 | 178 | BaseLimitTestSuite.setUp(self) | ||
807 | 179 | _limits = [ | ||
808 | 180 | Limit("GET", "*", ".*", 1, 60), | ||
809 | 181 | ] | ||
810 | 182 | self.app = limits.RateLimitingMiddleware(self._empty_app, _limits) | ||
811 | 183 | |||
812 | 184 | def test_good_request(self): | ||
813 | 185 | """Test successful GET request through middleware.""" | ||
814 | 186 | request = webob.Request.blank("/") | ||
815 | 187 | response = request.get_response(self.app) | ||
816 | 188 | self.assertEqual(200, response.status_int) | ||
817 | 189 | |||
818 | 190 | def test_limited_request_json(self): | ||
819 | 191 | """Test a rate-limited (403) GET request through middleware.""" | ||
820 | 192 | request = webob.Request.blank("/") | ||
821 | 193 | response = request.get_response(self.app) | ||
822 | 194 | self.assertEqual(200, response.status_int) | ||
823 | 195 | |||
824 | 196 | request = webob.Request.blank("/") | ||
825 | 197 | response = request.get_response(self.app) | ||
826 | 198 | self.assertEqual(response.status_int, 403) | ||
827 | 199 | |||
828 | 200 | body = json.loads(response.body) | ||
829 | 201 | expected = "Only 1 GET request(s) can be made to * every minute." | ||
830 | 202 | value = body["overLimitFault"]["details"].strip() | ||
831 | 203 | self.assertEqual(value, expected) | ||
832 | 204 | |||
833 | 205 | def test_limited_request_xml(self): | ||
834 | 206 | """Test a rate-limited (403) response as XML""" | ||
835 | 207 | request = webob.Request.blank("/") | ||
836 | 208 | response = request.get_response(self.app) | ||
837 | 209 | self.assertEqual(200, response.status_int) | ||
838 | 210 | |||
839 | 211 | request = webob.Request.blank("/") | ||
840 | 212 | request.accept = "application/xml" | ||
841 | 213 | response = request.get_response(self.app) | ||
842 | 214 | self.assertEqual(response.status_int, 403) | ||
843 | 215 | |||
844 | 216 | root = parseString(response.body).childNodes[0] | ||
845 | 217 | expected = "Only 1 GET request(s) can be made to * every minute." | ||
846 | 218 | |||
847 | 219 | details = root.getElementsByTagName("details") | ||
848 | 220 | self.assertEqual(details.length, 1) | ||
849 | 221 | |||
850 | 222 | value = details.item(0).firstChild.data.strip() | ||
851 | 223 | self.assertEqual(value, expected) | ||
852 | 224 | |||
853 | 225 | |||
854 | 226 | class LimitTest(BaseLimitTestSuite): | ||
855 | 227 | """ | ||
856 | 228 | Tests for the `limits.Limit` class. | ||
857 | 229 | """ | ||
858 | 230 | |||
859 | 231 | def test_GET_no_delay(self): | ||
860 | 232 | """Test a limit handles 1 GET per second.""" | ||
861 | 233 | limit = Limit("GET", "*", ".*", 1, 1) | ||
862 | 234 | delay = limit("GET", "/anything") | ||
863 | 235 | self.assertEqual(None, delay) | ||
864 | 236 | self.assertEqual(0, limit.next_request) | ||
865 | 237 | self.assertEqual(0, limit.last_request) | ||
866 | 238 | |||
867 | 239 | def test_GET_delay(self): | ||
868 | 240 | """Test two calls to 1 GET per second limit.""" | ||
869 | 241 | limit = Limit("GET", "*", ".*", 1, 1) | ||
870 | 242 | delay = limit("GET", "/anything") | ||
871 | 243 | self.assertEqual(None, delay) | ||
872 | 244 | |||
873 | 245 | delay = limit("GET", "/anything") | ||
874 | 246 | self.assertEqual(1, delay) | ||
875 | 247 | self.assertEqual(1, limit.next_request) | ||
876 | 248 | self.assertEqual(0, limit.last_request) | ||
877 | 249 | |||
878 | 250 | self.time += 4 | ||
879 | 251 | |||
880 | 252 | delay = limit("GET", "/anything") | ||
881 | 253 | self.assertEqual(None, delay) | ||
882 | 254 | self.assertEqual(4, limit.next_request) | ||
883 | 255 | self.assertEqual(4, limit.last_request) | ||
884 | 256 | |||
885 | 257 | |||
886 | 258 | class LimiterTest(BaseLimitTestSuite): | ||
887 | 259 | """ | ||
888 | 260 | Tests for the in-memory `limits.Limiter` class. | ||
889 | 261 | """ | ||
890 | 262 | |||
891 | 263 | def setUp(self): | ||
892 | 264 | """Run before each test.""" | ||
893 | 265 | BaseLimitTestSuite.setUp(self) | ||
894 | 266 | self.limiter = limits.Limiter(TEST_LIMITS) | ||
895 | 267 | |||
896 | 268 | def _check(self, num, verb, url, username=None): | ||
897 | 269 | """Check and yield results from checks.""" | ||
898 | 270 | for x in xrange(num): | ||
899 | 271 | yield self.limiter.check_for_delay(verb, url, username)[0] | ||
900 | 272 | |||
901 | 273 | def _check_sum(self, num, verb, url, username=None): | ||
902 | 274 | """Check and sum results from checks.""" | ||
903 | 275 | results = self._check(num, verb, url, username) | ||
904 | 276 | return sum(item for item in results if item) | ||
905 | 277 | |||
906 | 278 | def test_no_delay_GET(self): | ||
907 | 279 | """ | ||
908 | 280 | Simple test to ensure no delay on a single call for a limit verb we | ||
909 | 281 | didn"t set. | ||
910 | 282 | """ | ||
911 | 283 | delay = self.limiter.check_for_delay("GET", "/anything") | ||
912 | 284 | self.assertEqual(delay, (None, None)) | ||
913 | 285 | |||
914 | 286 | def test_no_delay_PUT(self): | ||
915 | 287 | """ | ||
916 | 288 | Simple test to ensure no delay on a single call for a known limit. | ||
917 | 289 | """ | ||
918 | 290 | delay = self.limiter.check_for_delay("PUT", "/anything") | ||
919 | 291 | self.assertEqual(delay, (None, None)) | ||
920 | 292 | |||
921 | 293 | def test_delay_PUT(self): | ||
922 | 294 | """ | ||
923 | 295 | Ensure the 11th PUT will result in a delay of 6.0 seconds until | ||
924 | 296 | the next request will be granced. | ||
925 | 297 | """ | ||
926 | 298 | expected = [None] * 10 + [6.0] | ||
927 | 299 | results = list(self._check(11, "PUT", "/anything")) | ||
928 | 300 | |||
929 | 301 | self.assertEqual(expected, results) | ||
930 | 302 | |||
931 | 303 | def test_delay_POST(self): | ||
932 | 304 | """ | ||
933 | 305 | Ensure the 8th POST will result in a delay of 6.0 seconds until | ||
934 | 306 | the next request will be granced. | ||
935 | 307 | """ | ||
936 | 308 | expected = [None] * 7 | ||
937 | 309 | results = list(self._check(7, "POST", "/anything")) | ||
938 | 310 | self.assertEqual(expected, results) | ||
939 | 311 | |||
940 | 312 | expected = 60.0 / 7.0 | ||
941 | 313 | results = self._check_sum(1, "POST", "/anything") | ||
942 | 314 | self.failUnlessAlmostEqual(expected, results, 8) | ||
943 | 315 | |||
944 | 316 | def test_delay_GET(self): | ||
945 | 317 | """ | ||
946 | 318 | Ensure the 11th GET will result in NO delay. | ||
947 | 319 | """ | ||
948 | 320 | expected = [None] * 11 | ||
949 | 321 | results = list(self._check(11, "GET", "/anything")) | ||
950 | 322 | |||
951 | 323 | self.assertEqual(expected, results) | ||
952 | 324 | |||
953 | 325 | def test_delay_PUT_servers(self): | ||
954 | 326 | """ | ||
955 | 327 | Ensure PUT on /servers limits at 5 requests, and PUT elsewhere is still | ||
956 | 328 | OK after 5 requests...but then after 11 total requests, PUT limiting | ||
957 | 329 | kicks in. | ||
958 | 330 | """ | ||
959 | 331 | # First 6 requests on PUT /servers | ||
960 | 332 | expected = [None] * 5 + [12.0] | ||
961 | 333 | results = list(self._check(6, "PUT", "/servers")) | ||
962 | 334 | self.assertEqual(expected, results) | ||
963 | 335 | |||
964 | 336 | # Next 5 request on PUT /anything | ||
965 | 337 | expected = [None] * 4 + [6.0] | ||
966 | 338 | results = list(self._check(5, "PUT", "/anything")) | ||
967 | 339 | self.assertEqual(expected, results) | ||
968 | 340 | |||
969 | 341 | def test_delay_PUT_wait(self): | ||
970 | 342 | """ | ||
971 | 343 | Ensure after hitting the limit and then waiting for the correct | ||
972 | 344 | amount of time, the limit will be lifted. | ||
973 | 345 | """ | ||
974 | 346 | expected = [None] * 10 + [6.0] | ||
975 | 347 | results = list(self._check(11, "PUT", "/anything")) | ||
976 | 348 | self.assertEqual(expected, results) | ||
977 | 349 | |||
978 | 350 | # Advance time | ||
979 | 351 | self.time += 6.0 | ||
980 | 352 | |||
981 | 353 | expected = [None, 6.0] | ||
982 | 354 | results = list(self._check(2, "PUT", "/anything")) | ||
983 | 355 | self.assertEqual(expected, results) | ||
984 | 356 | |||
985 | 357 | def test_multiple_delays(self): | ||
986 | 358 | """ | ||
987 | 359 | Ensure multiple requests still get a delay. | ||
988 | 360 | """ | ||
989 | 361 | expected = [None] * 10 + [6.0] * 10 | ||
990 | 362 | results = list(self._check(20, "PUT", "/anything")) | ||
991 | 363 | self.assertEqual(expected, results) | ||
992 | 364 | |||
993 | 365 | self.time += 1.0 | ||
994 | 366 | |||
995 | 367 | expected = [5.0] * 10 | ||
996 | 368 | results = list(self._check(10, "PUT", "/anything")) | ||
997 | 369 | self.assertEqual(expected, results) | ||
998 | 370 | |||
999 | 371 | def test_multiple_users(self): | ||
1000 | 372 | """ | ||
1001 | 373 | Tests involving multiple users. | ||
1002 | 374 | """ | ||
1003 | 375 | # User1 | ||
1004 | 376 | expected = [None] * 10 + [6.0] * 10 | ||
1005 | 377 | results = list(self._check(20, "PUT", "/anything", "user1")) | ||
1006 | 378 | self.assertEqual(expected, results) | ||
1007 | 379 | |||
1008 | 380 | # User2 | ||
1009 | 381 | expected = [None] * 10 + [6.0] * 5 | ||
1010 | 382 | results = list(self._check(15, "PUT", "/anything", "user2")) | ||
1011 | 383 | self.assertEqual(expected, results) | ||
1012 | 384 | |||
1013 | 385 | self.time += 1.0 | ||
1014 | 386 | |||
1015 | 387 | # User1 again | ||
1016 | 388 | expected = [5.0] * 10 | ||
1017 | 389 | results = list(self._check(10, "PUT", "/anything", "user1")) | ||
1018 | 390 | self.assertEqual(expected, results) | ||
1019 | 391 | |||
1020 | 392 | self.time += 1.0 | ||
1021 | 393 | |||
1022 | 394 | # User1 again | ||
1023 | 395 | expected = [4.0] * 5 | ||
1024 | 396 | results = list(self._check(5, "PUT", "/anything", "user2")) | ||
1025 | 397 | self.assertEqual(expected, results) | ||
1026 | 398 | |||
1027 | 399 | |||
1028 | 400 | class WsgiLimiterTest(BaseLimitTestSuite): | ||
1029 | 401 | """ | ||
1030 | 402 | Tests for `limits.WsgiLimiter` class. | ||
1031 | 403 | """ | ||
1032 | 404 | |||
1033 | 405 | def setUp(self): | ||
1034 | 406 | """Run before each test.""" | ||
1035 | 407 | BaseLimitTestSuite.setUp(self) | ||
1036 | 408 | self.app = limits.WsgiLimiter(TEST_LIMITS) | ||
1037 | 409 | |||
1038 | 410 | def _request_data(self, verb, path): | ||
1039 | 411 | """Get data decribing a limit request verb/path.""" | ||
1040 | 412 | return json.dumps({"verb": verb, "path": path}) | ||
1041 | 413 | |||
1042 | 414 | def _request(self, verb, url, username=None): | ||
1043 | 113 | """Make sure that POSTing to the given url causes the given username | 415 | """Make sure that POSTing to the given url causes the given username |
1044 | 114 | to perform the given action. Make the internal rate limiter return | 416 | to perform the given action. Make the internal rate limiter return |
1045 | 115 | delay and make sure that the WSGI app returns the correct response. | 417 | delay and make sure that the WSGI app returns the correct response. |
1046 | 116 | """ | 418 | """ |
1052 | 117 | req = webob.Request.blank(url, dict(REQUEST_METHOD='POST')) | 419 | if username: |
1053 | 118 | self.limiter.mock(action, username, delay) | 420 | request = webob.Request.blank("/%s" % username) |
1049 | 119 | resp = req.get_response(self.app) | ||
1050 | 120 | if not delay: | ||
1051 | 121 | self.assertEqual(resp.status_int, 200) | ||
1054 | 122 | else: | 421 | else: |
1060 | 123 | self.assertEqual(resp.status_int, 403) | 422 | request = webob.Request.blank("/") |
1061 | 124 | self.assertEqual(resp.headers['X-Wait-Seconds'], "%.2f" % delay) | 423 | |
1062 | 125 | 424 | request.method = "POST" | |
1063 | 126 | def test_good_urls(self): | 425 | request.body = self._request_data(verb, url) |
1064 | 127 | self.verify('/limiter/michael/hoot', 'michael', 'hoot') | 426 | response = request.get_response(self.app) |
1065 | 427 | |||
1066 | 428 | if "X-Wait-Seconds" in response.headers: | ||
1067 | 429 | self.assertEqual(response.status_int, 403) | ||
1068 | 430 | return response.headers["X-Wait-Seconds"] | ||
1069 | 431 | |||
1070 | 432 | self.assertEqual(response.status_int, 204) | ||
1071 | 433 | |||
1072 | 434 | def test_invalid_methods(self): | ||
1073 | 435 | """Only POSTs should work.""" | ||
1074 | 436 | requests = [] | ||
1075 | 437 | for method in ["GET", "PUT", "DELETE", "HEAD", "OPTIONS"]: | ||
1076 | 438 | request = webob.Request.blank("/") | ||
1077 | 439 | request.body = self._request_data("GET", "/something") | ||
1078 | 440 | response = request.get_response(self.app) | ||
1079 | 441 | self.assertEqual(response.status_int, 405) | ||
1080 | 442 | |||
1081 | 443 | def test_good_url(self): | ||
1082 | 444 | delay = self._request("GET", "/something") | ||
1083 | 445 | self.assertEqual(delay, None) | ||
1084 | 128 | 446 | ||
1085 | 129 | def test_escaping(self): | 447 | def test_escaping(self): |
1087 | 130 | self.verify('/limiter/michael/jump%20up', 'michael', 'jump up') | 448 | delay = self._request("GET", "/something/jump%20up") |
1088 | 449 | self.assertEqual(delay, None) | ||
1089 | 131 | 450 | ||
1090 | 132 | def test_response_to_delays(self): | 451 | def test_response_to_delays(self): |
1094 | 133 | self.verify('/limiter/michael/hoot', 'michael', 'hoot', 1) | 452 | delay = self._request("GET", "/delayed") |
1095 | 134 | self.verify('/limiter/michael/hoot', 'michael', 'hoot', 1.56) | 453 | self.assertEqual(delay, None) |
1096 | 135 | self.verify('/limiter/michael/hoot', 'michael', 'hoot', 1000) | 454 | |
1097 | 455 | delay = self._request("GET", "/delayed") | ||
1098 | 456 | self.assertEqual(delay, '60.00') | ||
1099 | 457 | |||
1100 | 458 | def test_response_to_delays_usernames(self): | ||
1101 | 459 | delay = self._request("GET", "/delayed", "user1") | ||
1102 | 460 | self.assertEqual(delay, None) | ||
1103 | 461 | |||
1104 | 462 | delay = self._request("GET", "/delayed", "user2") | ||
1105 | 463 | self.assertEqual(delay, None) | ||
1106 | 464 | |||
1107 | 465 | delay = self._request("GET", "/delayed", "user1") | ||
1108 | 466 | self.assertEqual(delay, '60.00') | ||
1109 | 467 | |||
1110 | 468 | delay = self._request("GET", "/delayed", "user2") | ||
1111 | 469 | self.assertEqual(delay, '60.00') | ||
1112 | 136 | 470 | ||
1113 | 137 | 471 | ||
1114 | 138 | class FakeHttplibSocket(object): | 472 | class FakeHttplibSocket(object): |
1116 | 139 | """a fake socket implementation for httplib.HTTPResponse, trivial""" | 473 | """ |
1117 | 474 | Fake `httplib.HTTPResponse` replacement. | ||
1118 | 475 | """ | ||
1119 | 140 | 476 | ||
1120 | 141 | def __init__(self, response_string): | 477 | def __init__(self, response_string): |
1121 | 478 | """Initialize new `FakeHttplibSocket`.""" | ||
1122 | 142 | self._buffer = StringIO.StringIO(response_string) | 479 | self._buffer = StringIO.StringIO(response_string) |
1123 | 143 | 480 | ||
1124 | 144 | def makefile(self, _mode, _other): | 481 | def makefile(self, _mode, _other): |
1126 | 145 | """Returns the socket's internal buffer""" | 482 | """Returns the socket's internal buffer.""" |
1127 | 146 | return self._buffer | 483 | return self._buffer |
1128 | 147 | 484 | ||
1129 | 148 | 485 | ||
1130 | 149 | class FakeHttplibConnection(object): | 486 | class FakeHttplibConnection(object): |
1132 | 150 | """A fake httplib.HTTPConnection | 487 | """ |
1133 | 488 | Fake `httplib.HTTPConnection`. | ||
1134 | 489 | """ | ||
1135 | 151 | 490 | ||
1141 | 152 | Requests made via this connection actually get translated and routed into | 491 | def __init__(self, app, host): |
1142 | 153 | our WSGI app, we then wait for the response and turn it back into | 492 | """ |
1143 | 154 | an httplib.HTTPResponse. | 493 | Initialize `FakeHttplibConnection`. |
1144 | 155 | """ | 494 | """ |
1140 | 156 | def __init__(self, app, host, is_secure=False): | ||
1145 | 157 | self.app = app | 495 | self.app = app |
1146 | 158 | self.host = host | 496 | self.host = host |
1147 | 159 | 497 | ||
1149 | 160 | def request(self, method, path, data='', headers={}): | 498 | def request(self, method, path, body="", headers={}): |
1150 | 499 | """ | ||
1151 | 500 | Requests made via this connection actually get translated and routed | ||
1152 | 501 | into our WSGI app, we then wait for the response and turn it back into | ||
1153 | 502 | an `httplib.HTTPResponse`. | ||
1154 | 503 | """ | ||
1155 | 161 | req = webob.Request.blank(path) | 504 | req = webob.Request.blank(path) |
1156 | 162 | req.method = method | 505 | req.method = method |
1157 | 163 | req.body = data | ||
1158 | 164 | req.headers = headers | 506 | req.headers = headers |
1159 | 165 | req.host = self.host | 507 | req.host = self.host |
1161 | 166 | # Call the WSGI app, get the HTTP response | 508 | req.body = body |
1162 | 509 | |||
1163 | 167 | resp = str(req.get_response(self.app)) | 510 | resp = str(req.get_response(self.app)) |
1164 | 168 | # For some reason, the response doesn't have "HTTP/1.0 " prepended; I | ||
1165 | 169 | # guess that's a function the web server usually provides. | ||
1166 | 170 | resp = "HTTP/1.0 %s" % resp | 511 | resp = "HTTP/1.0 %s" % resp |
1167 | 171 | sock = FakeHttplibSocket(resp) | 512 | sock = FakeHttplibSocket(resp) |
1168 | 172 | self.http_response = httplib.HTTPResponse(sock) | 513 | self.http_response = httplib.HTTPResponse(sock) |
1169 | 173 | self.http_response.begin() | 514 | self.http_response.begin() |
1170 | 174 | 515 | ||
1171 | 175 | def getresponse(self): | 516 | def getresponse(self): |
1172 | 517 | """Return our generated response from the request.""" | ||
1173 | 176 | return self.http_response | 518 | return self.http_response |
1174 | 177 | 519 | ||
1175 | 178 | 520 | ||
1176 | @@ -208,36 +550,35 @@ | |||
1177 | 208 | httplib.HTTPConnection = HTTPConnectionDecorator(httplib.HTTPConnection) | 550 | httplib.HTTPConnection = HTTPConnectionDecorator(httplib.HTTPConnection) |
1178 | 209 | 551 | ||
1179 | 210 | 552 | ||
1181 | 211 | class WSGIAppProxyTest(test.TestCase): | 553 | class WsgiLimiterProxyTest(BaseLimitTestSuite): |
1182 | 554 | """ | ||
1183 | 555 | Tests for the `limits.WsgiLimiterProxy` class. | ||
1184 | 556 | """ | ||
1185 | 212 | 557 | ||
1186 | 213 | def setUp(self): | 558 | def setUp(self): |
1201 | 214 | """Our WSGIAppProxy is going to call across an HTTPConnection to a | 559 | """ |
1202 | 215 | WSGIApp running a limiter. The proxy will send input, and the proxy | 560 | Do some nifty HTTP/WSGI magic which allows for WSGI to be called |
1203 | 216 | should receive that same input, pass it to the limiter who gives a | 561 | directly by something like the `httplib` library. |
1204 | 217 | result, and send the expected result back. | 562 | """ |
1205 | 218 | 563 | BaseLimitTestSuite.setUp(self) | |
1206 | 219 | The HTTPConnection isn't real -- it's monkeypatched to point straight | 564 | self.app = limits.WsgiLimiter(TEST_LIMITS) |
1207 | 220 | at the WSGIApp. And the limiter isn't real -- it's a fake that | 565 | wire_HTTPConnection_to_WSGI("169.254.0.1:80", self.app) |
1208 | 221 | behaves the way we tell it to. | 566 | self.proxy = limits.WsgiLimiterProxy("169.254.0.1:80") |
1195 | 222 | """ | ||
1196 | 223 | super(WSGIAppProxyTest, self).setUp() | ||
1197 | 224 | self.limiter = FakeLimiter(self) | ||
1198 | 225 | app = ratelimiting.WSGIApp(self.limiter) | ||
1199 | 226 | wire_HTTPConnection_to_WSGI('100.100.100.100:80', app) | ||
1200 | 227 | self.proxy = ratelimiting.WSGIAppProxy('100.100.100.100:80') | ||
1209 | 228 | 567 | ||
1210 | 229 | def test_200(self): | 568 | def test_200(self): |
1214 | 230 | self.limiter.mock('conquer', 'caesar', None) | 569 | """Successful request test.""" |
1215 | 231 | when = self.proxy.perform('conquer', 'caesar') | 570 | delay = self.proxy.check_for_delay("GET", "/anything") |
1216 | 232 | self.assertEqual(when, None) | 571 | self.assertEqual(delay, (None, None)) |
1217 | 233 | 572 | ||
1218 | 234 | def test_403(self): | 573 | def test_403(self): |
1228 | 235 | self.limiter.mock('grumble', 'proletariat', 1.5) | 574 | """Forbidden request test.""" |
1229 | 236 | when = self.proxy.perform('grumble', 'proletariat') | 575 | delay = self.proxy.check_for_delay("GET", "/delayed") |
1230 | 237 | self.assertEqual(when, 1.5) | 576 | self.assertEqual(delay, (None, None)) |
1231 | 238 | 577 | ||
1232 | 239 | def test_failure(self): | 578 | delay, error = self.proxy.check_for_delay("GET", "/delayed") |
1233 | 240 | def shouldRaise(): | 579 | error = error.strip() |
1234 | 241 | self.limiter.mock('murder', 'brutus', None) | 580 | |
1235 | 242 | self.proxy.perform('stab', 'brutus') | 581 | expected = ("60.00", "403 Forbidden\n\nOnly 1 GET request(s) can be "\ |
1236 | 243 | self.assertRaises(AssertionError, shouldRaise) | 582 | "made to /delayed every minute.") |
1237 | 583 | |||
1238 | 584 | self.assertEqual((delay, error), expected) |
Hi Brian! Just FYI, make sure you select "nova-core" as the reviewer when you do a merge request. You can add additional reviewers with the "Request another review" link on the merge proposal page (and I encourage you to do so, like you've done!)
The reason to do this is because nova-core sends email notifications to the dozen or so core committers, hopefully prodding more reviews :)
cheers!
jay