py-app-runner 0.5.49.dev0__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.
- py_app_runner/__init__.py +11 -0
- py_app_runner/audit/__init__.py +29 -0
- py_app_runner/audit/_service.py +91 -0
- py_app_runner/audit/_service_args.py +44 -0
- py_app_runner/audit/audit.py +319 -0
- py_app_runner/audit/commands.py +151 -0
- py_app_runner/audit/diff.py +202 -0
- py_app_runner/audit/errors.py +8 -0
- py_app_runner/audit/event.py +130 -0
- py_app_runner/audit/store.py +134 -0
- py_app_runner/bridge/__init__.py +0 -0
- py_app_runner/bridge/_service.py +265 -0
- py_app_runner/bridge/_service_args.py +24 -0
- py_app_runner/bridge/api.py +138 -0
- py_app_runner/bridge/encoders/__init__.py +5 -0
- py_app_runner/bridge/encoders/base.py +24 -0
- py_app_runner/bridge/encoders/json_encoder.py +26 -0
- py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
- py_app_runner/bridge/web_app.py +31 -0
- py_app_runner/bridge/websocket.py +313 -0
- py_app_runner/colors.py +73 -0
- py_app_runner/config.py +132 -0
- py_app_runner/crypto/__init__.py +14 -0
- py_app_runner/crypto/_service.py +75 -0
- py_app_runner/crypto/_service_args.py +54 -0
- py_app_runner/crypto/commands.py +164 -0
- py_app_runner/crypto/envelope.py +144 -0
- py_app_runner/crypto/errors.py +8 -0
- py_app_runner/crypto/fields.py +300 -0
- py_app_runner/crypto/passwords.py +66 -0
- py_app_runner/db_pools.py +20 -0
- py_app_runner/http_exception.py +31 -0
- py_app_runner/logger_handlers.py +167 -0
- py_app_runner/migrations/__init__.py +5 -0
- py_app_runner/migrations/_service.py +296 -0
- py_app_runner/migrations/_service_args.py +91 -0
- py_app_runner/migrations/commands.py +386 -0
- py_app_runner/migrations/discovery.py +108 -0
- py_app_runner/migrations/states.py +63 -0
- py_app_runner/migrations/tracker.py +141 -0
- py_app_runner/py.typed +0 -0
- py_app_runner/pybridge.py +64 -0
- py_app_runner/queue/__init__.py +25 -0
- py_app_runner/queue/_service.py +231 -0
- py_app_runner/queue/_service_args.py +67 -0
- py_app_runner/queue/commands.py +180 -0
- py_app_runner/queue/driver_pg.py +464 -0
- py_app_runner/queue/driver_redis.py +613 -0
- py_app_runner/queue/handler.py +90 -0
- py_app_runner/queue/interface.py +63 -0
- py_app_runner/queue/job.py +46 -0
- py_app_runner/queue/worker.py +221 -0
- py_app_runner/registry.py +54 -0
- py_app_runner/request_handler/__init__.py +0 -0
- py_app_runner/request_handler/auth_service.py +123 -0
- py_app_runner/request_handler/decorators.py +304 -0
- py_app_runner/request_handler/handlers.py +604 -0
- py_app_runner/request_handler/pagination.py +24 -0
- py_app_runner/return_model.py +78 -0
- py_app_runner/runner.py +182 -0
- py_app_runner/throttle/__init__.py +5 -0
- py_app_runner/throttle/throttle.py +217 -0
- py_app_runner/tick_service.py +308 -0
- py_app_runner/timer.py +289 -0
- py_app_runner/utils.py +346 -0
- py_app_runner/wbcm/__init__.py +0 -0
- py_app_runner/wbcm/device_connections.py +89 -0
- py_app_runner/wbcm/factory.py +113 -0
- py_app_runner/wbcm/wb_connection_manager.py +333 -0
- py_app_runner/wbcm/ws_interface.py +56 -0
- py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
- py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
- py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
- py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
- py_app_runner-0.5.49.dev0.dist-info/top_level.txt +1 -0
py_app_runner/runner.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# ruff: noqa: E402 # Ignore module level import not at top of file
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
import uvloop
|
|
11
|
+
|
|
12
|
+
from py_app_runner.config import is_env_dev, is_env_prod
|
|
13
|
+
from py_app_runner.logger_handlers import ConsoleHandler, InitSentry
|
|
14
|
+
from py_app_runner.pybridge import PyBridge
|
|
15
|
+
from py_app_runner.registry import AppRegistry
|
|
16
|
+
from py_app_runner.utils import json_encode
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> None:
|
|
20
|
+
"""Service initialization"""
|
|
21
|
+
|
|
22
|
+
# Ensure src/ is on the path so project modules (config, services, etc.) are importable
|
|
23
|
+
cwd = os.getcwd()
|
|
24
|
+
src_path = os.path.join(cwd, "src")
|
|
25
|
+
if os.path.isdir(src_path) and src_path not in sys.path:
|
|
26
|
+
sys.path.insert(0, src_path)
|
|
27
|
+
|
|
28
|
+
if not os.path.isfile(os.path.join(cwd, ".env")):
|
|
29
|
+
raise FileNotFoundError("There is no .env file, exiting...")
|
|
30
|
+
|
|
31
|
+
config = AppRegistry.config()
|
|
32
|
+
|
|
33
|
+
# Logging flags live on a help-less pre-parser so they can be parsed before
|
|
34
|
+
# subparsers exist; `-h` is then handled only by the full parser below and
|
|
35
|
+
# renders complete per-service help.
|
|
36
|
+
pre_parser = argparse.ArgumentParser(add_help=False)
|
|
37
|
+
pre_parser.add_argument(
|
|
38
|
+
"-v",
|
|
39
|
+
type=str,
|
|
40
|
+
choices=["debug", "info", "warning", "error", "disable"],
|
|
41
|
+
default="info",
|
|
42
|
+
help="Console logging level",
|
|
43
|
+
)
|
|
44
|
+
pre_parser.add_argument(
|
|
45
|
+
"-sv",
|
|
46
|
+
type=str,
|
|
47
|
+
choices=["debug", "info", "warning", "error", "disable"],
|
|
48
|
+
default="error",
|
|
49
|
+
help=(
|
|
50
|
+
"Sentry logging level, depends on -v. If -v is set to disable, then no error will be sent, "
|
|
51
|
+
"also if -v is set to info, and this is set to info, only errors will be reported."
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
pre_parser.add_argument(
|
|
55
|
+
"-vf",
|
|
56
|
+
type=str,
|
|
57
|
+
default=None,
|
|
58
|
+
help="Console logging filter by module name",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
parser = argparse.ArgumentParser(description="PyBridge services", parents=[pre_parser])
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--interval",
|
|
64
|
+
type=int,
|
|
65
|
+
default=None,
|
|
66
|
+
help="""
|
|
67
|
+
Interval in seconds. Used in services that supports it.
|
|
68
|
+
A service should chose their own defaults.
|
|
69
|
+
""",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--run-once",
|
|
73
|
+
action="store_true",
|
|
74
|
+
help="Run job only once",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--dry-run",
|
|
78
|
+
action="store_true",
|
|
79
|
+
help="Dry run mode, no data will be saved, if subservice supports it",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Parse args for logging output first
|
|
83
|
+
args, _unknown = pre_parser.parse_known_args()
|
|
84
|
+
|
|
85
|
+
# Init logging
|
|
86
|
+
consoleHandler = ConsoleHandler(filter=args.vf)
|
|
87
|
+
logger = logging.getLogger()
|
|
88
|
+
logger.addHandler(consoleHandler)
|
|
89
|
+
|
|
90
|
+
consoleLogger = logging.getLogger("console")
|
|
91
|
+
consoleLogger.addHandler(consoleHandler)
|
|
92
|
+
consoleLogger.propagate = False
|
|
93
|
+
|
|
94
|
+
if args.v == "disable":
|
|
95
|
+
logging.disable(logging.CRITICAL)
|
|
96
|
+
else:
|
|
97
|
+
logger.setLevel(getattr(logging, args.v.upper()))
|
|
98
|
+
consoleLogger.setLevel(getattr(logging, args.v.upper()))
|
|
99
|
+
|
|
100
|
+
config["debug"] = args.v.upper() == "DEBUG"
|
|
101
|
+
|
|
102
|
+
# Init sentry
|
|
103
|
+
if is_env_prod(config):
|
|
104
|
+
InitSentry(
|
|
105
|
+
args,
|
|
106
|
+
config["sentry"]["dsn"],
|
|
107
|
+
config["git_commit_hash"],
|
|
108
|
+
config["environment"],
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if is_env_dev(config):
|
|
112
|
+
# Enable debug mode for asyncio in development environment
|
|
113
|
+
os.environ["PYTHONASYNCIODEBUG"] = "1"
|
|
114
|
+
else:
|
|
115
|
+
# Remove debug mode for asyncio in production and test environments
|
|
116
|
+
os.environ.pop("PYTHONASYNCIODEBUG", None)
|
|
117
|
+
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
|
118
|
+
|
|
119
|
+
# Looking for service argument parsers...
|
|
120
|
+
pybridge = PyBridge()
|
|
121
|
+
subparsers = parser.add_subparsers(
|
|
122
|
+
title="service",
|
|
123
|
+
description="Type of service",
|
|
124
|
+
help="Type of service",
|
|
125
|
+
dest="service",
|
|
126
|
+
)
|
|
127
|
+
subparsers.add_parser("print_config", help="Prints the config")
|
|
128
|
+
|
|
129
|
+
logger.debug("Looking for services subparsers...", extra={"prefix": "\n\n"})
|
|
130
|
+
for item in config["services"]:
|
|
131
|
+
service = pybridge.load_service_args(item)
|
|
132
|
+
|
|
133
|
+
logBuffer = f"{item}:"
|
|
134
|
+
|
|
135
|
+
# Register subparser
|
|
136
|
+
logBuffer += " subparser: "
|
|
137
|
+
if service and hasattr(service, "reg_subparsers"):
|
|
138
|
+
service.reg_subparsers(subparsers, pybridge, logger)
|
|
139
|
+
logBuffer += "Yes"
|
|
140
|
+
else:
|
|
141
|
+
logBuffer += "No"
|
|
142
|
+
|
|
143
|
+
# Log info
|
|
144
|
+
logger.debug(logBuffer)
|
|
145
|
+
|
|
146
|
+
# Second parse args for services
|
|
147
|
+
args = parser.parse_args()
|
|
148
|
+
|
|
149
|
+
# Start the service...
|
|
150
|
+
if args.service is None:
|
|
151
|
+
parser.print_help()
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
if args.service == "print_config":
|
|
155
|
+
logger.info("Config dump:")
|
|
156
|
+
print(json_encode(config, pretty=True))
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
logger.debug(f"\n\nLoad service: {args.service}")
|
|
160
|
+
|
|
161
|
+
service = pybridge.load_service_runner(args.service)
|
|
162
|
+
|
|
163
|
+
if not service:
|
|
164
|
+
logger.error(f'Service "{args.service}" not found!')
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
if not hasattr(service, "init_service"):
|
|
168
|
+
logger.error(f'"init_service" method not found on {args.service}!')
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
logger.debug(f'Starting service: "{args.service}"', extra={"prefix": "\n\n"})
|
|
172
|
+
|
|
173
|
+
# Start the service
|
|
174
|
+
try:
|
|
175
|
+
logger.info(f'Started service "{args.service}"')
|
|
176
|
+
asyncio.run(service.init_service(args, pybridge, logger))
|
|
177
|
+
|
|
178
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
179
|
+
... # Ignore this exception
|
|
180
|
+
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.exception(f'Uncaught service "{args.service}" exception: {repr(e)}')
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Rate limiting: count attempts against a key, and say when the caller may try again.
|
|
2
|
+
|
|
3
|
+
Fixed window rather than sliding: the window opens on the first hit and closes `window`
|
|
4
|
+
seconds later, whatever happens in between. The known cost is the boundary - a client can
|
|
5
|
+
spend a full allowance at the end of one window and another at the start of the next, so
|
|
6
|
+
for a limit of 5 per 15 minutes the true worst case is 10 in quick succession. That is
|
|
7
|
+
documented here rather than left to be discovered, and it is the right trade for the thing
|
|
8
|
+
this protects: a login form, not a billing meter.
|
|
9
|
+
|
|
10
|
+
attempt = await throttle.hit(f"login:{email}", 5, 900)
|
|
11
|
+
if not attempt.allowed:
|
|
12
|
+
raise HTTPException("Too many attempts", code=4029, http_status=429)
|
|
13
|
+
|
|
14
|
+
# ... and the moment the protected thing succeeds:
|
|
15
|
+
await throttle.clear(f"login:{email}")
|
|
16
|
+
|
|
17
|
+
Clearing on success is what stops a user who mistyped their password four times and then
|
|
18
|
+
got it right from staying one attempt away from a lockout for the rest of the window.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import logging
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
_logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_DEFAULT_PREFIX = "throttle:"
|
|
30
|
+
|
|
31
|
+
# One round trip, and exact. Read-modify-write over separate commands would let simultaneous
|
|
32
|
+
# requests read the same count and each let an extra attempt through; a script costs the same
|
|
33
|
+
# latency and has no such window, so there is no reason to accept the race.
|
|
34
|
+
#
|
|
35
|
+
# The reset timestamp is stored rather than derived from the key's TTL. It is what decides
|
|
36
|
+
# whether a window is over, so it has to survive a backend that dropped the expiry - which
|
|
37
|
+
# is exactly what a crash between a SET and its EXPIRE leaves behind.
|
|
38
|
+
_HIT_SCRIPT = """
|
|
39
|
+
local hits = tonumber(redis.call('HGET', KEYS[1], 'hits'))
|
|
40
|
+
local reset = tonumber(redis.call('HGET', KEYS[1], 'reset'))
|
|
41
|
+
local now = tonumber(ARGV[1])
|
|
42
|
+
local window = tonumber(ARGV[2])
|
|
43
|
+
|
|
44
|
+
if hits == nil or reset == nil or reset <= now then
|
|
45
|
+
hits = 0
|
|
46
|
+
reset = now + window
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
hits = hits + 1
|
|
50
|
+
|
|
51
|
+
redis.call('HSET', KEYS[1], 'hits', hits, 'reset', reset)
|
|
52
|
+
redis.call('EXPIRE', KEYS[1], math.max(1, reset - now))
|
|
53
|
+
|
|
54
|
+
return {hits, reset}
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Attempt:
|
|
60
|
+
"""The state of one key after an attempt.
|
|
61
|
+
|
|
62
|
+
A value object rather than a bool because every caller that denies a request also has
|
|
63
|
+
to tell the client when to come back, and a bool makes that a second lookup.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
allowed: bool
|
|
67
|
+
limit: int
|
|
68
|
+
hits: int
|
|
69
|
+
remaining: int
|
|
70
|
+
retry_after: int
|
|
71
|
+
reset_at: int
|
|
72
|
+
|
|
73
|
+
def headers(self) -> dict[str, str]:
|
|
74
|
+
"""The standard advertisement headers. `Retry-After` only when denied - sending it
|
|
75
|
+
on a successful response tells a well-behaved client to back off when it need not."""
|
|
76
|
+
|
|
77
|
+
headers = {
|
|
78
|
+
"X-RateLimit-Limit": str(self.limit),
|
|
79
|
+
"X-RateLimit-Remaining": str(self.remaining),
|
|
80
|
+
# An absolute unix timestamp, not a delta: a delta computed at send time is
|
|
81
|
+
# already wrong by the time it is read.
|
|
82
|
+
"X-RateLimit-Reset": str(self.reset_at),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if not self.allowed:
|
|
86
|
+
headers["Retry-After"] = str(self.retry_after)
|
|
87
|
+
|
|
88
|
+
return headers
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Throttle:
|
|
92
|
+
def __init__(self, redis_con: Any, prefix: str = _DEFAULT_PREFIX, fail_open: bool = True) -> None:
|
|
93
|
+
self.redis_con = redis_con
|
|
94
|
+
self.prefix = prefix
|
|
95
|
+
# Redis being unreachable is an outage of the cache, not of the application.
|
|
96
|
+
# Refusing every login until it comes back turns a degraded dependency into a total
|
|
97
|
+
# one, so the default is to allow and log. It is a choice, not a silence.
|
|
98
|
+
self.fail_open = fail_open
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_config(cls, redis_con: Any, config: dict[str, Any]) -> "Throttle":
|
|
102
|
+
settings = config.get("throttle") or {}
|
|
103
|
+
return cls(
|
|
104
|
+
redis_con=redis_con,
|
|
105
|
+
prefix=settings.get("prefix") or _DEFAULT_PREFIX,
|
|
106
|
+
fail_open=settings.get("fail_open") is not False,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def cache_key(self, key: str) -> str:
|
|
110
|
+
"""Hash the caller's key.
|
|
111
|
+
|
|
112
|
+
It is routinely an email address or an IP, and a shared Redis keyspace is readable
|
|
113
|
+
by anything else that connects to it. Hashing costs nothing here and means the
|
|
114
|
+
keyspace is not a list of who has been failing to log in.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
return self.prefix + hashlib.sha256(key.encode("utf-8")).hexdigest()
|
|
118
|
+
|
|
119
|
+
async def hit(self, key: str, max_attempts: int, window: int) -> Attempt:
|
|
120
|
+
"""Count one attempt and report the result.
|
|
121
|
+
|
|
122
|
+
The attempt is counted whether or not it is allowed, so hammering neither resets
|
|
123
|
+
the window nor extends it.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
self._assert_limit(max_attempts, window)
|
|
127
|
+
now = int(time.time())
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
hits, reset_at = await self._run_hit(key, now, window)
|
|
131
|
+
except Exception as e:
|
|
132
|
+
return self._unavailable(e, max_attempts, now, window)
|
|
133
|
+
|
|
134
|
+
return self._attempt(hits, reset_at, max_attempts, now)
|
|
135
|
+
|
|
136
|
+
async def check(self, key: str, max_attempts: int) -> Attempt:
|
|
137
|
+
"""Peek without counting. Use it to show a client where it stands."""
|
|
138
|
+
|
|
139
|
+
self._assert_limit(max_attempts, 1)
|
|
140
|
+
now = int(time.time())
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
stored = await self.redis_con.hmget(self.cache_key(key), "hits", "reset")
|
|
144
|
+
except Exception as e:
|
|
145
|
+
return self._unavailable(e, max_attempts, now, 0)
|
|
146
|
+
|
|
147
|
+
hits, reset_at = self._parse(stored)
|
|
148
|
+
if hits is None or reset_at is None or reset_at <= now:
|
|
149
|
+
# Nothing stored, or a window that has closed: a full allowance. reset_at is
|
|
150
|
+
# `now` rather than `now + window` because check() is not told the window
|
|
151
|
+
# length and must not invent one.
|
|
152
|
+
return Attempt(True, max_attempts, 0, max_attempts, 0, now)
|
|
153
|
+
|
|
154
|
+
return self._attempt(hits, reset_at, max_attempts, now)
|
|
155
|
+
|
|
156
|
+
async def clear(self, key: str) -> None:
|
|
157
|
+
"""Forget a key. Call it the moment the protected thing succeeds."""
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
await self.redis_con.delete(self.cache_key(key))
|
|
161
|
+
except Exception as e:
|
|
162
|
+
if not self.fail_open:
|
|
163
|
+
raise
|
|
164
|
+
|
|
165
|
+
_logger.warning("throttle: could not clear a counter: %s", e)
|
|
166
|
+
|
|
167
|
+
###############
|
|
168
|
+
### Interna ###
|
|
169
|
+
###############
|
|
170
|
+
|
|
171
|
+
async def _run_hit(self, key: str, now: int, window: int) -> tuple[int, int]:
|
|
172
|
+
result = await self.redis_con.eval(_HIT_SCRIPT, 1, self.cache_key(key), now, window)
|
|
173
|
+
return (int(result[0]), int(result[1]))
|
|
174
|
+
|
|
175
|
+
def _attempt(self, hits: int, reset_at: int, max_attempts: int, now: int) -> Attempt:
|
|
176
|
+
# Increment-then-compare, so hits == max is the last allowed attempt and
|
|
177
|
+
# hits == max + 1 is the first denial.
|
|
178
|
+
allowed = hits <= max_attempts
|
|
179
|
+
|
|
180
|
+
return Attempt(
|
|
181
|
+
allowed=allowed,
|
|
182
|
+
limit=max_attempts,
|
|
183
|
+
hits=hits,
|
|
184
|
+
remaining=max(0, max_attempts - hits),
|
|
185
|
+
retry_after=0 if allowed else max(0, reset_at - now),
|
|
186
|
+
reset_at=reset_at,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def _parse(self, stored: Any) -> tuple[int | None, int | None]:
|
|
190
|
+
"""Read stored state, treating anything unrecognisable as absent.
|
|
191
|
+
|
|
192
|
+
A key of this name holding something else - a leftover from another tool, a
|
|
193
|
+
half-written value - starts a fresh window rather than raising. Throwing here would
|
|
194
|
+
take down the login form over a stray cache entry.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
if not stored or len(stored) != 2:
|
|
198
|
+
return (None, None)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
return (int(stored[0]), int(stored[1]))
|
|
202
|
+
except (TypeError, ValueError):
|
|
203
|
+
return (None, None)
|
|
204
|
+
|
|
205
|
+
def _unavailable(self, error: Exception, max_attempts: int, now: int, window: int) -> Attempt:
|
|
206
|
+
if not self.fail_open:
|
|
207
|
+
raise error
|
|
208
|
+
|
|
209
|
+
_logger.warning("throttle: %s", error)
|
|
210
|
+
return Attempt(True, max_attempts, 0, max_attempts, 0, now + window)
|
|
211
|
+
|
|
212
|
+
def _assert_limit(self, max_attempts: int, window: int) -> None:
|
|
213
|
+
if max_attempts < 1:
|
|
214
|
+
raise ValueError(f"A throttle limit is at least 1 attempt; got {max_attempts}.")
|
|
215
|
+
|
|
216
|
+
if window < 1:
|
|
217
|
+
raise ValueError(f"A throttle window is at least 1 second; got {window}.")
|