queryapigate 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of queryapigate might be problematic. Click here for more details.
- queryapigate/__init__.py +6 -0
- queryapigate/__main__.py +3 -0
- queryapigate/apikeys.py +535 -0
- queryapigate/app.py +844 -0
- queryapigate/cache.py +61 -0
- queryapigate/cli.py +168 -0
- queryapigate/config.py +268 -0
- queryapigate/cors.py +51 -0
- queryapigate/engine.py +166 -0
- queryapigate/errors.py +8 -0
- queryapigate/formats.py +193 -0
- queryapigate/lib/h2-2.2.224.jar +0 -0
- queryapigate/logging_setup.py +75 -0
- queryapigate/metrics.py +215 -0
- queryapigate/openapi.py +410 -0
- queryapigate/params.py +275 -0
- queryapigate/pool.py +146 -0
- queryapigate/ratelimit.py +88 -0
- queryapigate/runners.py +795 -0
- queryapigate/schema.py +99 -0
- queryapigate/sqltools.py +184 -0
- queryapigate/store.py +433 -0
- queryapigate/ui.py +2371 -0
- queryapigate-0.5.0.dist-info/METADATA +708 -0
- queryapigate-0.5.0.dist-info/RECORD +29 -0
- queryapigate-0.5.0.dist-info/WHEEL +5 -0
- queryapigate-0.5.0.dist-info/entry_points.txt +2 -0
- queryapigate-0.5.0.dist-info/licenses/LICENSE +21 -0
- queryapigate-0.5.0.dist-info/top_level.txt +1 -0
queryapigate/params.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Parameter definitions of a saved query: checking the definitions, and enforcing them on request values.
|
|
2
|
+
|
|
3
|
+
A saved query declares its parameters in ``query_parameters``. The short form is just a type
|
|
4
|
+
(``{"id": "int"}``); the full form adds rules::
|
|
5
|
+
|
|
6
|
+
{
|
|
7
|
+
"rating": {"type": "str", "enum": ["G", "PG", "R"], "default": "PG", "description": "MPAA rating"},
|
|
8
|
+
"max_length": {"type": "int", "min": 1, "max": 600},
|
|
9
|
+
"title": {"type": "str", "required": false, "min_length": 2, "pattern": "[A-Za-z ]+"}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
Rules: ``type`` (int, float, str, bool), ``required`` (default: true unless a ``default`` is given), ``default``,
|
|
13
|
+
``enum``, ``min``/``max`` (numbers), ``min_length``/``max_length``/``pattern`` (text) and ``description``.
|
|
14
|
+
An optional parameter that is neither supplied nor defaulted is bound as NULL.
|
|
15
|
+
|
|
16
|
+
Definitions are validated strictly when a query is saved. At run time they are read leniently, so older saved
|
|
17
|
+
files with unusual definitions keep working.
|
|
18
|
+
"""
|
|
19
|
+
import math
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
from .errors import ApiError
|
|
23
|
+
|
|
24
|
+
_TYPES = {'int': 'integer', 'integer': 'integer', 'float': 'number', 'number': 'number',
|
|
25
|
+
'str': 'string', 'string': 'string', 'bool': 'boolean', 'boolean': 'boolean'}
|
|
26
|
+
_RULE_KEYS = {'type', 'required', 'default', 'enum', 'min', 'max', 'min_length', 'max_length', 'pattern',
|
|
27
|
+
'description'}
|
|
28
|
+
_NAME_RE = re.compile(r'^[A-Za-z_]\w*$')
|
|
29
|
+
_MAX_PATTERN_LENGTH = 500
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Invalid(Exception):
|
|
33
|
+
"""A value or a definition that breaks a rule; the message completes the sentence '<name> ...'."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _to_bool(text):
|
|
37
|
+
lowered = text.strip().lower()
|
|
38
|
+
if lowered in ('true', '1', 'yes'):
|
|
39
|
+
return True
|
|
40
|
+
if lowered in ('false', '0', 'no'):
|
|
41
|
+
return False
|
|
42
|
+
raise ValueError(text)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_number(value):
|
|
46
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _plain(spec):
|
|
50
|
+
return {'type': None, 'required': True, 'has_default': False, 'default': None, 'enum': None, 'min': None,
|
|
51
|
+
'max': None, 'min_length': None, 'max_length': None, 'pattern': None, 'description': None, **spec}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _coerce(type_name, value):
|
|
55
|
+
"""Convert a value (usually a query-string text) to the declared type, or raise _Invalid."""
|
|
56
|
+
if type_name == 'integer':
|
|
57
|
+
if isinstance(value, str):
|
|
58
|
+
try:
|
|
59
|
+
return int(value)
|
|
60
|
+
except ValueError:
|
|
61
|
+
raise _Invalid('must be an integer') from None
|
|
62
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
63
|
+
return value
|
|
64
|
+
raise _Invalid('must be an integer')
|
|
65
|
+
if type_name == 'number':
|
|
66
|
+
if isinstance(value, str):
|
|
67
|
+
try:
|
|
68
|
+
value = float(value)
|
|
69
|
+
except ValueError:
|
|
70
|
+
raise _Invalid('must be a number') from None
|
|
71
|
+
if _is_number(value):
|
|
72
|
+
return value
|
|
73
|
+
raise _Invalid('must be a number')
|
|
74
|
+
if type_name == 'boolean':
|
|
75
|
+
if isinstance(value, str):
|
|
76
|
+
try:
|
|
77
|
+
return _to_bool(value)
|
|
78
|
+
except ValueError:
|
|
79
|
+
raise _Invalid('must be true or false') from None
|
|
80
|
+
if isinstance(value, bool):
|
|
81
|
+
return value
|
|
82
|
+
raise _Invalid('must be true or false')
|
|
83
|
+
if type_name == 'string':
|
|
84
|
+
if isinstance(value, str):
|
|
85
|
+
return value
|
|
86
|
+
raise _Invalid('must be text')
|
|
87
|
+
if isinstance(value, (str, bool)) or _is_number(value): # untyped: any scalar
|
|
88
|
+
return value
|
|
89
|
+
raise _Invalid('must be text, a number or true/false')
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _check(spec, value):
|
|
93
|
+
"""Return the value converted to the declared type after applying every rule, or raise _Invalid."""
|
|
94
|
+
value = _coerce(spec['type'], value)
|
|
95
|
+
if spec['enum'] is not None and value not in spec['enum']:
|
|
96
|
+
raise _Invalid('must be one of: ' + ', '.join(str(v) for v in spec['enum']))
|
|
97
|
+
if _is_number(value):
|
|
98
|
+
if spec['min'] is not None and value < spec['min']:
|
|
99
|
+
raise _Invalid(f"must be at least {spec['min']:g}")
|
|
100
|
+
if spec['max'] is not None and value > spec['max']:
|
|
101
|
+
raise _Invalid(f"must be at most {spec['max']:g}")
|
|
102
|
+
if isinstance(value, str):
|
|
103
|
+
if spec['min_length'] is not None and len(value) < spec['min_length']:
|
|
104
|
+
raise _Invalid(f"must be at least {spec['min_length']} characters long")
|
|
105
|
+
if spec['max_length'] is not None and len(value) > spec['max_length']:
|
|
106
|
+
raise _Invalid(f"must be at most {spec['max_length']} characters long")
|
|
107
|
+
if spec['pattern'] is not None and not re.fullmatch(spec['pattern'], value):
|
|
108
|
+
raise _Invalid(f"must match the pattern {spec['pattern']}")
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --------------------------------------------------------------------------------------
|
|
113
|
+
# Definitions
|
|
114
|
+
# --------------------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def _parse_strict(spec):
|
|
117
|
+
if isinstance(spec, str):
|
|
118
|
+
spec = {'type': spec}
|
|
119
|
+
if not isinstance(spec, dict):
|
|
120
|
+
raise _Invalid("must be a type name such as 'int' or an object of rules")
|
|
121
|
+
unknown = sorted(set(spec) - _RULE_KEYS)
|
|
122
|
+
if unknown:
|
|
123
|
+
raise _Invalid(f"has unknown rule(s): {', '.join(unknown)} (allowed: {', '.join(sorted(_RULE_KEYS))})")
|
|
124
|
+
|
|
125
|
+
type_name = None
|
|
126
|
+
if spec.get('type') is not None:
|
|
127
|
+
type_name = _TYPES.get(str(spec['type']).lower())
|
|
128
|
+
if type_name is None:
|
|
129
|
+
raise _Invalid(f"has unknown type '{spec['type']}' (use int, float, str or bool)")
|
|
130
|
+
parsed = _plain({'type': type_name})
|
|
131
|
+
|
|
132
|
+
if 'required' in spec:
|
|
133
|
+
if not isinstance(spec['required'], bool):
|
|
134
|
+
raise _Invalid("'required' must be true or false")
|
|
135
|
+
parsed['required'] = spec['required']
|
|
136
|
+
elif 'default' in spec:
|
|
137
|
+
parsed['required'] = False
|
|
138
|
+
if 'description' in spec:
|
|
139
|
+
if not isinstance(spec['description'], str):
|
|
140
|
+
raise _Invalid("'description' must be text")
|
|
141
|
+
parsed['description'] = spec['description']
|
|
142
|
+
|
|
143
|
+
numeric = type_name in (None, 'integer', 'number')
|
|
144
|
+
textual = type_name in (None, 'string')
|
|
145
|
+
for key in ('min', 'max'):
|
|
146
|
+
if key in spec:
|
|
147
|
+
if not numeric:
|
|
148
|
+
raise _Invalid(f"'{key}' only applies to numbers (use min_length/max_length for text)")
|
|
149
|
+
if not _is_number(spec[key]):
|
|
150
|
+
raise _Invalid(f"'{key}' must be a number")
|
|
151
|
+
parsed[key] = spec[key]
|
|
152
|
+
if parsed['min'] is not None and parsed['max'] is not None and parsed['min'] > parsed['max']:
|
|
153
|
+
raise _Invalid("'min' must not be greater than 'max'")
|
|
154
|
+
for key in ('min_length', 'max_length'):
|
|
155
|
+
if key in spec:
|
|
156
|
+
if not textual:
|
|
157
|
+
raise _Invalid(f"'{key}' only applies to text")
|
|
158
|
+
if not isinstance(spec[key], int) or isinstance(spec[key], bool) or spec[key] < 0:
|
|
159
|
+
raise _Invalid(f"'{key}' must be a non-negative integer")
|
|
160
|
+
parsed[key] = spec[key]
|
|
161
|
+
if (parsed['min_length'] is not None and parsed['max_length'] is not None
|
|
162
|
+
and parsed['min_length'] > parsed['max_length']):
|
|
163
|
+
raise _Invalid("'min_length' must not be greater than 'max_length'")
|
|
164
|
+
if 'pattern' in spec:
|
|
165
|
+
if not textual:
|
|
166
|
+
raise _Invalid("'pattern' only applies to text")
|
|
167
|
+
if not isinstance(spec['pattern'], str) or len(spec['pattern']) > _MAX_PATTERN_LENGTH:
|
|
168
|
+
raise _Invalid(f"'pattern' must be text of at most {_MAX_PATTERN_LENGTH} characters")
|
|
169
|
+
try:
|
|
170
|
+
re.compile(spec['pattern'])
|
|
171
|
+
except re.error as error:
|
|
172
|
+
raise _Invalid(f"'pattern' is not a valid regular expression ({error})") from None
|
|
173
|
+
parsed['pattern'] = spec['pattern']
|
|
174
|
+
|
|
175
|
+
if 'enum' in spec:
|
|
176
|
+
if not isinstance(spec['enum'], list) or not spec['enum']:
|
|
177
|
+
raise _Invalid("'enum' must be a non-empty list")
|
|
178
|
+
try:
|
|
179
|
+
parsed['enum'] = [_coerce(type_name, v) for v in spec['enum']]
|
|
180
|
+
except _Invalid as error:
|
|
181
|
+
raise _Invalid(f"has an 'enum' value that {error}") from None
|
|
182
|
+
if 'default' in spec:
|
|
183
|
+
if spec.get('required') is True:
|
|
184
|
+
raise _Invalid("cannot be both 'required' and have a 'default'")
|
|
185
|
+
try:
|
|
186
|
+
parsed['default'] = _check(parsed, spec['default'])
|
|
187
|
+
except _Invalid as error:
|
|
188
|
+
raise _Invalid(f"has a 'default' that {error}") from None
|
|
189
|
+
parsed['has_default'] = True
|
|
190
|
+
return parsed
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def parse_definitions(query_parameters):
|
|
194
|
+
"""Validate the ``query_parameters`` of a query about to be saved; raises ApiError(400) listing every problem."""
|
|
195
|
+
if not isinstance(query_parameters, dict):
|
|
196
|
+
raise ApiError('query_parameters must be a JSON object')
|
|
197
|
+
errors = {}
|
|
198
|
+
for name, spec in query_parameters.items():
|
|
199
|
+
if not _NAME_RE.match(name):
|
|
200
|
+
errors[name] = 'is not a valid parameter name (letters, digits and underscores; not starting with a digit)'
|
|
201
|
+
continue
|
|
202
|
+
try:
|
|
203
|
+
_parse_strict(spec)
|
|
204
|
+
except _Invalid as error:
|
|
205
|
+
errors[name] = str(error)
|
|
206
|
+
if errors:
|
|
207
|
+
raise ApiError('Invalid query_parameters: ' + '; '.join(f'{n} {m}' for n, m in errors.items()), 400,
|
|
208
|
+
errors=errors)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def read_definition(spec):
|
|
212
|
+
"""A definition as the normalised rule dict; anything unusable degrades to an untyped, required parameter."""
|
|
213
|
+
try:
|
|
214
|
+
return _parse_strict(spec)
|
|
215
|
+
except _Invalid:
|
|
216
|
+
if isinstance(spec, str):
|
|
217
|
+
return _plain({'type': _TYPES.get(spec.lower())})
|
|
218
|
+
return _plain({})
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def read_definitions(query_parameters):
|
|
222
|
+
if not isinstance(query_parameters, dict):
|
|
223
|
+
return {}
|
|
224
|
+
return {name: read_definition(spec) for name, spec in query_parameters.items()}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# --------------------------------------------------------------------------------------
|
|
228
|
+
# Values
|
|
229
|
+
# --------------------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
def resolve(declared, supplied, used=None):
|
|
232
|
+
"""Apply a saved query's parameter definitions to the values a request supplied.
|
|
233
|
+
|
|
234
|
+
Returns every value to bind: supplied ones converted and checked, defaults filled in, optional ones without
|
|
235
|
+
a value set to None. Values for undeclared parameters pass through untouched. Declared parameters that the
|
|
236
|
+
SQL does not use (``used`` is the set of names it does use) are not enforced. Raises ApiError(400) listing
|
|
237
|
+
every violation.
|
|
238
|
+
"""
|
|
239
|
+
values = dict(supplied)
|
|
240
|
+
errors = {}
|
|
241
|
+
for name, spec in read_definitions(declared).items():
|
|
242
|
+
if used is not None and name not in used:
|
|
243
|
+
continue
|
|
244
|
+
if name not in values:
|
|
245
|
+
if spec['has_default']:
|
|
246
|
+
values[name] = spec['default']
|
|
247
|
+
elif spec['required']:
|
|
248
|
+
errors[name] = 'is required'
|
|
249
|
+
else:
|
|
250
|
+
values[name] = None
|
|
251
|
+
continue
|
|
252
|
+
if values[name] is None:
|
|
253
|
+
if spec['required']:
|
|
254
|
+
errors[name] = 'must not be null'
|
|
255
|
+
continue
|
|
256
|
+
try:
|
|
257
|
+
values[name] = _check(spec, values[name])
|
|
258
|
+
except _Invalid as error:
|
|
259
|
+
errors[name] = str(error)
|
|
260
|
+
if errors:
|
|
261
|
+
raise ApiError('Invalid parameters: ' + '; '.join(f'{n} {m}' for n, m in errors.items()), 400,
|
|
262
|
+
errors=errors)
|
|
263
|
+
return values
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def json_schema(spec):
|
|
267
|
+
"""The OpenAPI/JSON-schema description of a parameter's type and rules."""
|
|
268
|
+
schema = {'type': spec['type'] or 'string'}
|
|
269
|
+
for rule, key in (('enum', 'enum'), ('min', 'minimum'), ('max', 'maximum'), ('min_length', 'minLength'),
|
|
270
|
+
('max_length', 'maxLength'), ('pattern', 'pattern')):
|
|
271
|
+
if spec[rule] is not None:
|
|
272
|
+
schema[key] = spec[rule]
|
|
273
|
+
if spec['has_default']:
|
|
274
|
+
schema['default'] = spec['default']
|
|
275
|
+
return schema
|
queryapigate/pool.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""A small thread-safe pool of live database connections.
|
|
2
|
+
|
|
3
|
+
Only *idle* connections are pooled: a connection is checked out exclusively by one request, and on the way
|
|
4
|
+
back it is reset (its transaction ended) and kept for reuse - unless the request failed, in which case its
|
|
5
|
+
state is unknown and it is closed instead. The number of concurrent connections is not limited here; the
|
|
6
|
+
pool only bounds how many idle ones are kept around.
|
|
7
|
+
"""
|
|
8
|
+
import atexit
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections import deque
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
|
|
17
|
+
from . import config
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger('queryapigate')
|
|
20
|
+
|
|
21
|
+
# A connection that has been idle for less than this is trusted without a round trip to check it.
|
|
22
|
+
VALIDATE_AFTER = 5.0 # seconds
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Session:
|
|
26
|
+
"""A connection plus what the driver wants to remember about it (e.g. limits already applied)."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, conn, driver):
|
|
29
|
+
self.conn = conn
|
|
30
|
+
self.driver = driver
|
|
31
|
+
self.state = {}
|
|
32
|
+
self.idle_since = time.monotonic()
|
|
33
|
+
|
|
34
|
+
def close(self):
|
|
35
|
+
try:
|
|
36
|
+
self.driver.close(self)
|
|
37
|
+
except Exception: # a connection that cannot be closed cleanly is gone anyway
|
|
38
|
+
log.debug('Error while closing a connection', exc_info=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fingerprint(details, read_only):
|
|
42
|
+
"""Pool key: connections are interchangeable when their settings and read-only mode are identical."""
|
|
43
|
+
blob = json.dumps(details, sort_keys=True, default=str)
|
|
44
|
+
return hashlib.sha256(blob.encode('utf-8')).hexdigest(), bool(read_only)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ConnectionPool:
|
|
48
|
+
def __init__(self):
|
|
49
|
+
self._idle = {} # key -> deque of Sessions, least recently used on the left
|
|
50
|
+
self._lock = threading.Lock()
|
|
51
|
+
|
|
52
|
+
@contextmanager
|
|
53
|
+
def checkout(self, driver, details, read_only):
|
|
54
|
+
key = fingerprint(details, read_only)
|
|
55
|
+
session = self._acquire(key)
|
|
56
|
+
if session is None:
|
|
57
|
+
session = Session(driver.connect(details, read_only), driver)
|
|
58
|
+
try:
|
|
59
|
+
yield session
|
|
60
|
+
except BaseException:
|
|
61
|
+
session.close() # after an error its state is unknown - never reuse it
|
|
62
|
+
raise
|
|
63
|
+
self._release(key, session)
|
|
64
|
+
|
|
65
|
+
# ---- internals ------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def _acquire(self, key):
|
|
68
|
+
while True:
|
|
69
|
+
with self._lock:
|
|
70
|
+
queue = self._idle.get(key)
|
|
71
|
+
if not queue:
|
|
72
|
+
return None
|
|
73
|
+
session = queue.pop() # most recently used first, so spare connections age out
|
|
74
|
+
if not queue:
|
|
75
|
+
del self._idle[key]
|
|
76
|
+
idle_for = time.monotonic() - session.idle_since
|
|
77
|
+
if idle_for > config.pool_idle_timeout():
|
|
78
|
+
session.close()
|
|
79
|
+
continue
|
|
80
|
+
if idle_for > VALIDATE_AFTER:
|
|
81
|
+
try:
|
|
82
|
+
alive = session.driver.is_alive(session)
|
|
83
|
+
except Exception:
|
|
84
|
+
alive = False
|
|
85
|
+
if not alive:
|
|
86
|
+
log.info('Discarding a dead pooled connection')
|
|
87
|
+
session.close()
|
|
88
|
+
continue
|
|
89
|
+
return session
|
|
90
|
+
|
|
91
|
+
def _release(self, key, session):
|
|
92
|
+
try:
|
|
93
|
+
session.driver.reset(session) # ends the transaction so the next user sees fresh data
|
|
94
|
+
except Exception:
|
|
95
|
+
log.debug('Could not reset a connection; closing it', exc_info=True)
|
|
96
|
+
session.close()
|
|
97
|
+
return
|
|
98
|
+
session.idle_since = time.monotonic()
|
|
99
|
+
surplus = []
|
|
100
|
+
with self._lock:
|
|
101
|
+
queue = self._idle.setdefault(key, deque())
|
|
102
|
+
queue.append(session)
|
|
103
|
+
while len(queue) > config.pool_size():
|
|
104
|
+
surplus.append(queue.popleft())
|
|
105
|
+
surplus.extend(self._expired_locked())
|
|
106
|
+
for old in surplus:
|
|
107
|
+
old.close()
|
|
108
|
+
|
|
109
|
+
def _expired_locked(self):
|
|
110
|
+
"""Remove and return idle sessions past the idle timeout, across every key (caller holds the lock)."""
|
|
111
|
+
cutoff = time.monotonic() - config.pool_idle_timeout()
|
|
112
|
+
expired = []
|
|
113
|
+
for key in list(self._idle):
|
|
114
|
+
queue = self._idle[key]
|
|
115
|
+
while queue and queue[0].idle_since < cutoff:
|
|
116
|
+
expired.append(queue.popleft())
|
|
117
|
+
if not queue:
|
|
118
|
+
del self._idle[key]
|
|
119
|
+
return expired
|
|
120
|
+
|
|
121
|
+
# ---- housekeeping ---------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def idle_count(self):
|
|
124
|
+
with self._lock:
|
|
125
|
+
return sum(len(q) for q in self._idle.values())
|
|
126
|
+
|
|
127
|
+
def close_all(self):
|
|
128
|
+
with self._lock:
|
|
129
|
+
sessions = [s for queue in self._idle.values() for s in queue]
|
|
130
|
+
self._idle.clear()
|
|
131
|
+
for session in sessions:
|
|
132
|
+
session.close()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
_default = ConnectionPool()
|
|
136
|
+
atexit.register(_default.close_all)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def close_pooled_connections():
|
|
140
|
+
"""Close every idle connection in the shared pool (also runs at interpreter exit)."""
|
|
141
|
+
_default.close_all()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_pool():
|
|
145
|
+
"""The shared pool, or None when pooling is disabled (QUERYAPIGATE_POOL_SIZE=0)."""
|
|
146
|
+
return _default if config.pool_size() > 0 else None
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""A small in-memory token-bucket rate limiter, off unless QUERYAPIGATE_RATE_LIMIT is set (e.g. ``60/minute``).
|
|
2
|
+
|
|
3
|
+
Each client (by IP address) has a bucket holding up to the full quota, refilled continuously, so short bursts are
|
|
4
|
+
allowed but the sustained rate is capped. State lives in this process: with several worker processes each has its
|
|
5
|
+
own buckets, so the effective limit is multiplied by the number of workers.
|
|
6
|
+
|
|
7
|
+
``RateLimiter`` handles the server-wide, IP-keyed limit above. ``KeyRateLimiters`` (below) is the same
|
|
8
|
+
mechanism applied per API key instead, for a key's own optional ``rate_limit`` grant (see ``apikeys.py``) -
|
|
9
|
+
both are checked, not one instead of the other, so a per-key limit can never be used to escape the
|
|
10
|
+
server-wide IP-based one.
|
|
11
|
+
"""
|
|
12
|
+
import math
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from collections import OrderedDict
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RateLimiter:
|
|
19
|
+
MAX_CLIENTS = 100_000 # bounds memory; the least recently seen clients are forgotten first
|
|
20
|
+
|
|
21
|
+
def __init__(self, clock=time.monotonic):
|
|
22
|
+
self._clock = clock
|
|
23
|
+
self._lock = threading.Lock()
|
|
24
|
+
self._buckets = OrderedDict() # client -> [tokens, last_seen]; least recently seen first
|
|
25
|
+
self._spec = None
|
|
26
|
+
self._last_sweep = clock()
|
|
27
|
+
|
|
28
|
+
def hit(self, client, count, period):
|
|
29
|
+
"""Record a request. Returns (allowed, remaining, retry_after_seconds)."""
|
|
30
|
+
now = self._clock()
|
|
31
|
+
with self._lock:
|
|
32
|
+
if self._spec != (count, period): # limit changed: start again rather than mix old and new rules
|
|
33
|
+
self._buckets.clear()
|
|
34
|
+
self._spec = (count, period)
|
|
35
|
+
self._sweep(now, period)
|
|
36
|
+
tokens, seen = self._buckets.pop(client, (float(count), now))
|
|
37
|
+
tokens = min(float(count), tokens + (now - seen) * count / period)
|
|
38
|
+
allowed = tokens >= 1
|
|
39
|
+
if allowed:
|
|
40
|
+
tokens -= 1
|
|
41
|
+
self._buckets[client] = (tokens, now) # re-inserted last: most recently seen
|
|
42
|
+
if len(self._buckets) > self.MAX_CLIENTS:
|
|
43
|
+
self._buckets.popitem(last=False)
|
|
44
|
+
# round first: 20.000000000000004 seconds must not become a 21 second wait
|
|
45
|
+
retry_after = 0 if allowed else max(1, math.ceil(round((1 - tokens) * period / count, 6)))
|
|
46
|
+
return allowed, int(tokens), retry_after
|
|
47
|
+
|
|
48
|
+
def _sweep(self, now, period):
|
|
49
|
+
"""Forget clients idle for a full period: their bucket has refilled, so they look brand new anyway."""
|
|
50
|
+
if now - self._last_sweep < period:
|
|
51
|
+
return
|
|
52
|
+
self._last_sweep = now
|
|
53
|
+
while self._buckets:
|
|
54
|
+
client, (_, seen) = next(iter(self._buckets.items()))
|
|
55
|
+
if now - seen < period:
|
|
56
|
+
break
|
|
57
|
+
del self._buckets[client]
|
|
58
|
+
|
|
59
|
+
def size(self):
|
|
60
|
+
with self._lock:
|
|
61
|
+
return len(self._buckets)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class KeyRateLimiters:
|
|
65
|
+
"""Per-API-key rate limiting needs a genuinely separate RateLimiter per distinct (count, period) spec,
|
|
66
|
+
not just a different `client` identifier fed into one shared instance: a single RateLimiter enforces
|
|
67
|
+
exactly one spec at a time and wipes every client's bucket the moment a *different* spec arrives (see
|
|
68
|
+
its own `hit()` - by design, so an admin changing QUERYAPIGATE_RATE_LIMIT doesn't mix old and new rules for
|
|
69
|
+
the IP-based limiter). Two keys configured with different `rate_limit` values are exactly that "two
|
|
70
|
+
different specs" case, so they need their own RateLimiter instances; two keys that happen to share the
|
|
71
|
+
same configured limit correctly share one instance instead (and so still get independent per-key
|
|
72
|
+
buckets within it, keyed by name) - there is no reason to multiply instances beyond one per distinct
|
|
73
|
+
spec actually in use.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, clock=time.monotonic):
|
|
77
|
+
self._clock = clock
|
|
78
|
+
self._lock = threading.Lock()
|
|
79
|
+
self._limiters = {} # (count, period) -> RateLimiter
|
|
80
|
+
|
|
81
|
+
def hit(self, key_name, count, period):
|
|
82
|
+
spec = (count, period)
|
|
83
|
+
with self._lock:
|
|
84
|
+
limiter = self._limiters.get(spec)
|
|
85
|
+
if limiter is None:
|
|
86
|
+
limiter = RateLimiter(self._clock)
|
|
87
|
+
self._limiters[spec] = limiter
|
|
88
|
+
return limiter.hit(key_name, count, period)
|