wsbuilder 1.2.4__tar.gz → 1.2.5__tar.gz
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.
- {wsbuilder-1.2.4/src/wsbuilder.egg-info → wsbuilder-1.2.5}/PKG-INFO +1 -1
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/pyproject.toml +1 -1
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/__init__.py +1 -1
- {wsbuilder-1.2.4 → wsbuilder-1.2.5/src/wsbuilder.egg-info}/PKG-INFO +1 -1
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder.egg-info/SOURCES.txt +1 -0
- wsbuilder-1.2.5/tests/test_proxyi_routing.py +349 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/LICENSE +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/MANIFEST.in +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/README.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/404.html +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/CNAME +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/advanced.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/ai-predict.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/api-map.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/app-http.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/architecture.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/assets/wsbuilder-mark.svg +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/beginners.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/cache-security.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/cache.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/capabilities.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/data-orm.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/dns.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/full-project.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/getting-started.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/help/index.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/http-versions.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/http.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/ia.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/index.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/install.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/intermediate.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/mdns.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/network-edge.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/observability-tasks.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/observability.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/persistence.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/proxy.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/reference/index.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/security.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/stylesheets/extra.css +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/tasks.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/tls-certificates.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/docs/websocket.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/examples/full-project/.gitignore +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/examples/full-project/README.md +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/examples/full-project/app.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/examples/full-project/pyproject.toml +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/examples/full-project/upstream.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/mkdocs.yml +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/setup.cfg +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/__main__.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/_coerce.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/app.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/cache.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/caches.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/constants.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/cookies.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/db_replicas.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/dns.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/framework.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/headers.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/hpack.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/http.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/http1.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/http2.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/http3.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/http3_server.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/http3_websocket.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/ia.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/logs.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/mdns.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/metrics.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/orm.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/pki.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/predicts.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/proxyi.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/qpack.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/quic/__init__.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/quic/address.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/quic/connection.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/quic/crypto.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/quic/frames.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/quic/packet.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/quic/recovery.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/quic/tls.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/quic/varint.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/security.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/server.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/tasks.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder/ws.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder.egg-info/dependency_links.txt +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder.egg-info/entry_points.txt +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder.egg-info/requires.txt +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/src/wsbuilder.egg-info/top_level.txt +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_app_threads.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_cache.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_caches.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_contributing_policy.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_demo_monitor.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_dns.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_docs.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_documentation_files.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_headers_cookies.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_hpack.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_http.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_http1.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_http2.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_http3.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_http3_server.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_http3_streaming.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_http3_streaming_udp.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_ia.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_logs.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_mdns.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_metrics.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_orm.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_orm_replicas.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_pki.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_pr_identity_policy.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_predicts.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_proxyi.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_quic_address.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_quic_crypto.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_quic_protocol.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_quic_recovery.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_quic_tls.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_security.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_server.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_server_errors.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_tasks.py +0 -0
- {wsbuilder-1.2.4 → wsbuilder-1.2.5}/tests/test_ws.py +0 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""ProxyI routing, balancing, builder and dispatch paths that need no backend."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from wsbuilder import App, ProxyI, ProxyTarget, Request
|
|
8
|
+
from wsbuilder.proxyi import (
|
|
9
|
+
BALANCING_BEST,
|
|
10
|
+
BALANCING_CONSISTENT_HASH,
|
|
11
|
+
BALANCING_FIRST_AVAILABLE,
|
|
12
|
+
BALANCING_LEAST_BYTES_IN,
|
|
13
|
+
BALANCING_LEAST_BYTES_OUT,
|
|
14
|
+
BALANCING_LEAST_CONNECTIONS,
|
|
15
|
+
BALANCING_LEAST_REQUESTS,
|
|
16
|
+
BALANCING_LEAST_RESPONSE_TIME,
|
|
17
|
+
BALANCING_POWER_OF_TWO_CHOICES,
|
|
18
|
+
BALANCING_RANDOM,
|
|
19
|
+
BALANCING_ROUND_ROBIN,
|
|
20
|
+
BALANCING_WEIGHTED_ROUND_ROBIN,
|
|
21
|
+
SUPPORTED_BALANCING_STRATEGIES,
|
|
22
|
+
ProxyRule,
|
|
23
|
+
install_proxyi,
|
|
24
|
+
normalize_balance_mode,
|
|
25
|
+
normalize_target,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _request(path="/", *, method="GET", host="example.test", headers=None, body=b"", query="", client=None):
|
|
30
|
+
request_headers = {"host": host} if host else {}
|
|
31
|
+
request_headers.update(headers or {})
|
|
32
|
+
return Request(
|
|
33
|
+
method=method,
|
|
34
|
+
path=path,
|
|
35
|
+
query_string=query,
|
|
36
|
+
headers=request_headers,
|
|
37
|
+
body=body,
|
|
38
|
+
client=client or ("127.0.0.1", 40000),
|
|
39
|
+
tls={},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _closed_port():
|
|
44
|
+
with socket.socket() as sock:
|
|
45
|
+
sock.bind(("127.0.0.1", 0))
|
|
46
|
+
return sock.getsockname()[1]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _served(target, *, request_bytes=0, response_bytes=0, latency_ms=1.0, error=None, status=200):
|
|
50
|
+
target.metrics.begin_request(request_bytes)
|
|
51
|
+
target.metrics.finish_request(status, response_size=response_bytes, latency_ms=latency_ms, error=error)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestNormalization(unittest.TestCase):
|
|
55
|
+
def test_balance_aliases_resolve_to_supported_modes(self):
|
|
56
|
+
cases = {
|
|
57
|
+
"rr": BALANCING_ROUND_ROBIN,
|
|
58
|
+
"WRR": BALANCING_WEIGHTED_ROUND_ROBIN,
|
|
59
|
+
"least-conn": BALANCING_LEAST_CONNECTIONS,
|
|
60
|
+
"Least_Response": BALANCING_LEAST_RESPONSE_TIME,
|
|
61
|
+
"leastreq": BALANCING_LEAST_REQUESTS,
|
|
62
|
+
"consistent-hash": BALANCING_CONSISTENT_HASH,
|
|
63
|
+
"first": BALANCING_FIRST_AVAILABLE,
|
|
64
|
+
"power_of_two": BALANCING_POWER_OF_TWO_CHOICES,
|
|
65
|
+
"best_effort": BALANCING_BEST,
|
|
66
|
+
"no-such-mode": BALANCING_ROUND_ROBIN,
|
|
67
|
+
}
|
|
68
|
+
for alias, expected in cases.items():
|
|
69
|
+
with self.subTest(alias=alias):
|
|
70
|
+
self.assertEqual(normalize_balance_mode(alias), expected)
|
|
71
|
+
for mode in SUPPORTED_BALANCING_STRATEGIES:
|
|
72
|
+
self.assertEqual(normalize_balance_mode(mode), mode)
|
|
73
|
+
|
|
74
|
+
def test_targets_accept_instances_mappings_and_urls(self):
|
|
75
|
+
target = ProxyTarget("http://127.0.0.1:9000", name="a")
|
|
76
|
+
self.assertIs(normalize_target(target), target)
|
|
77
|
+
|
|
78
|
+
mapped = normalize_target({"url": "http://127.0.0.1:9001"}, name="b", weight=3)
|
|
79
|
+
self.assertEqual((mapped.name, mapped.weight, mapped.port), ("b", 3, 9001))
|
|
80
|
+
|
|
81
|
+
replaced = normalize_target("ignored", url="https://upstream.test/base")
|
|
82
|
+
self.assertEqual((replaced.scheme, replaced.host, replaced.base_path), ("https", "upstream.test", "/base"))
|
|
83
|
+
|
|
84
|
+
with self.assertRaisesRegex(ValueError, "requires a url"):
|
|
85
|
+
normalize_target({"name": "missing"})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TestBalancing(unittest.TestCase):
|
|
89
|
+
def _rule(self, balance, *names, **rule_options):
|
|
90
|
+
rule = ProxyRule(name="lb", balance=balance, **rule_options)
|
|
91
|
+
for index, name in enumerate(names):
|
|
92
|
+
rule.add_target({"url": f"http://127.0.0.1:{9100 + index}", "name": name})
|
|
93
|
+
return rule
|
|
94
|
+
|
|
95
|
+
def _pick(self, rule, request=None):
|
|
96
|
+
return rule.choose_target(request or _request()).name
|
|
97
|
+
|
|
98
|
+
def test_weighted_round_robin_follows_the_weights_smoothly(self):
|
|
99
|
+
rule = ProxyRule(name="wrr", balance=BALANCING_WEIGHTED_ROUND_ROBIN)
|
|
100
|
+
rule.add_target({"url": "http://127.0.0.1:9100", "name": "heavy", "weight": 3})
|
|
101
|
+
rule.add_target({"url": "http://127.0.0.1:9101", "name": "light", "weight": 1})
|
|
102
|
+
|
|
103
|
+
picks = [self._pick(rule) for _ in range(8)]
|
|
104
|
+
|
|
105
|
+
self.assertEqual(picks[:4], ["heavy", "heavy", "light", "heavy"])
|
|
106
|
+
self.assertEqual(picks.count("heavy"), 6)
|
|
107
|
+
|
|
108
|
+
def test_least_modes_prefer_the_idlest_target(self):
|
|
109
|
+
# Both targets served one plain request; "busy" then carries more of
|
|
110
|
+
# whatever the mode measures.
|
|
111
|
+
cases = (
|
|
112
|
+
(BALANCING_LEAST_CONNECTIONS, lambda busy: busy.metrics.begin_request(0)),
|
|
113
|
+
(BALANCING_LEAST_REQUESTS, lambda busy: _served(busy)),
|
|
114
|
+
(BALANCING_LEAST_BYTES_IN, lambda busy: _served(busy, request_bytes=4096)),
|
|
115
|
+
(BALANCING_LEAST_BYTES_OUT, lambda busy: _served(busy, response_bytes=4096)),
|
|
116
|
+
)
|
|
117
|
+
for mode, load in cases:
|
|
118
|
+
with self.subTest(mode=mode):
|
|
119
|
+
rule = self._rule(mode, "busy", "idle")
|
|
120
|
+
busy, idle = rule.targets
|
|
121
|
+
_served(busy)
|
|
122
|
+
_served(idle)
|
|
123
|
+
load(busy)
|
|
124
|
+
self.assertEqual(self._pick(rule), "idle")
|
|
125
|
+
|
|
126
|
+
def test_best_avoids_a_target_that_fails(self):
|
|
127
|
+
rule = self._rule(BALANCING_BEST, "flaky", "steady")
|
|
128
|
+
flaky, steady = rule.targets
|
|
129
|
+
_served(flaky, error="refused", status=502)
|
|
130
|
+
_served(steady)
|
|
131
|
+
self.assertEqual(self._pick(rule), "steady")
|
|
132
|
+
|
|
133
|
+
def test_first_available_skips_disabled_targets(self):
|
|
134
|
+
rule = self._rule(BALANCING_FIRST_AVAILABLE, "off", "on", "spare")
|
|
135
|
+
rule.targets[0].enabled = False
|
|
136
|
+
self.assertEqual({self._pick(rule) for _ in range(3)}, {"on"})
|
|
137
|
+
|
|
138
|
+
def test_consistent_hash_pins_a_key_to_one_target(self):
|
|
139
|
+
rule = self._rule(BALANCING_CONSISTENT_HASH, "a", "b", "c", hash_key="x-tenant")
|
|
140
|
+
|
|
141
|
+
pinned = {self._pick(rule, _request(headers={"x-tenant": "acme"})) for _ in range(5)}
|
|
142
|
+
spread = {self._pick(rule, _request(headers={"x-tenant": f"tenant-{n}"})) for n in range(40)}
|
|
143
|
+
|
|
144
|
+
self.assertEqual(len(pinned), 1)
|
|
145
|
+
self.assertGreater(len(spread), 1)
|
|
146
|
+
|
|
147
|
+
def test_consistent_hash_without_a_key_uses_the_host(self):
|
|
148
|
+
rule = self._rule(BALANCING_CONSISTENT_HASH, "a", "b", "c")
|
|
149
|
+
picks = {self._pick(rule, _request(host="tenant.example.test")) for _ in range(5)}
|
|
150
|
+
self.assertEqual(len(picks), 1)
|
|
151
|
+
|
|
152
|
+
def test_random_choices_stay_within_the_enabled_targets(self):
|
|
153
|
+
for mode in (BALANCING_RANDOM, BALANCING_POWER_OF_TWO_CHOICES):
|
|
154
|
+
with self.subTest(mode=mode):
|
|
155
|
+
rule = self._rule(mode, "a", "b", "off")
|
|
156
|
+
rule.targets[2].enabled = False
|
|
157
|
+
self.assertLessEqual({self._pick(rule) for _ in range(30)}, {"a", "b"})
|
|
158
|
+
single = self._rule(mode, "only")
|
|
159
|
+
self.assertEqual(self._pick(single), "only")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class TestMatching(unittest.TestCase):
|
|
163
|
+
def test_host_patterns_contains_and_regex(self):
|
|
164
|
+
wildcard = ProxyRule(hosts=("*.example.test",))
|
|
165
|
+
contains = ProxyRule(host_contains=("tenant",))
|
|
166
|
+
regex = ProxyRule(host_regex=(r"^api-\d+\.",))
|
|
167
|
+
|
|
168
|
+
self.assertTrue(wildcard.matches(_request(host="api.example.test")))
|
|
169
|
+
self.assertFalse(wildcard.matches(_request(host="example.org")))
|
|
170
|
+
self.assertTrue(contains.matches(_request(host="tenant7.example.test")))
|
|
171
|
+
self.assertTrue(regex.matches(_request(host="API-12.example.test")))
|
|
172
|
+
self.assertFalse(regex.matches(_request(host="api-x.example.test")))
|
|
173
|
+
# A rule that needs a host never matches a request without one.
|
|
174
|
+
self.assertFalse(wildcard.matches(_request(host="")))
|
|
175
|
+
self.assertTrue(ProxyRule().matches(_request(host="")))
|
|
176
|
+
|
|
177
|
+
def test_path_methods_and_headers(self):
|
|
178
|
+
rule = ProxyRule(
|
|
179
|
+
path_contains=("/v2/",),
|
|
180
|
+
path_regex=(r"/items/\d+$",),
|
|
181
|
+
methods=("get", "post"),
|
|
182
|
+
header_regex={"x-client": r"^mobile-"},
|
|
183
|
+
)
|
|
184
|
+
good = _request("/api/v2/items/42", headers={"x-client": "Mobile-iOS"})
|
|
185
|
+
|
|
186
|
+
self.assertTrue(rule.matches(good))
|
|
187
|
+
self.assertFalse(rule.matches(_request("/api/v2/items/42", method="DELETE", headers={"x-client": "mobile-a"})))
|
|
188
|
+
self.assertFalse(rule.matches(_request("/api/v1/items/42", headers={"x-client": "mobile-a"})))
|
|
189
|
+
self.assertFalse(rule.matches(_request("/api/v2/items/42", headers={"x-client": "desktop"})))
|
|
190
|
+
self.assertTrue(ProxyRule(path="/exact").matches(_request("/exact")))
|
|
191
|
+
self.assertFalse(ProxyRule(path="/exact").matches(_request("/exact/more")))
|
|
192
|
+
|
|
193
|
+
def test_priority_then_specificity_decide_between_matching_rules(self):
|
|
194
|
+
proxy = ProxyI()
|
|
195
|
+
proxy.location("/api", name="prefix").upstream("http://127.0.0.1:9300").build()
|
|
196
|
+
proxy.route(name="exact", path="/api/users").upstream("http://127.0.0.1:9301").build()
|
|
197
|
+
proxy.default("http://127.0.0.1:9302", name="catch-all")
|
|
198
|
+
|
|
199
|
+
self.assertEqual(proxy._resolve_rules(_request("/api/users"))[0].name, "exact")
|
|
200
|
+
self.assertEqual(proxy._resolve_rules(_request("/api/other"))[0].name, "prefix")
|
|
201
|
+
self.assertEqual(proxy._resolve_rules(_request("/elsewhere"))[0].default, True)
|
|
202
|
+
|
|
203
|
+
proxy.location("/api", name="urgent", priority=10).upstream("http://127.0.0.1:9303").build()
|
|
204
|
+
self.assertEqual(proxy._resolve_rules(_request("/api/users"))[0].name, "urgent")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class TestRouteBuilder(unittest.TestCase):
|
|
208
|
+
def test_changes_after_the_first_upstream_reach_the_built_rule(self):
|
|
209
|
+
proxy = ProxyI(default_balance="least_conn")
|
|
210
|
+
builder = proxy.route(name="api").upstream("http://127.0.0.1:9400")
|
|
211
|
+
rule = proxy.routes()[0]
|
|
212
|
+
|
|
213
|
+
(
|
|
214
|
+
builder.host("API.Example.Test")
|
|
215
|
+
.host_contains("example")
|
|
216
|
+
.host_regex(r"\.test$")
|
|
217
|
+
.location(r"^/v\d+/", regex=True)
|
|
218
|
+
.header_equals("X-Env", "Prod")
|
|
219
|
+
.header_contains("x-role", "admin")
|
|
220
|
+
.balance("wrr")
|
|
221
|
+
.priority("5")
|
|
222
|
+
.strip_prefix()
|
|
223
|
+
.preserve_host(False)
|
|
224
|
+
.hash_key("x-user")
|
|
225
|
+
.to("http://127.0.0.1:9401")
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
self.assertEqual(rule.hosts, ("api.example.test",))
|
|
229
|
+
self.assertEqual(rule.host_contains, ("example",))
|
|
230
|
+
self.assertEqual(rule.path_regex, (r"^/v\d+/",))
|
|
231
|
+
self.assertEqual(rule.header_equals, {"x-env": "prod"})
|
|
232
|
+
self.assertEqual(rule.balance, BALANCING_WEIGHTED_ROUND_ROBIN)
|
|
233
|
+
self.assertEqual(rule.priority, 5)
|
|
234
|
+
self.assertTrue(rule.strip_prefix)
|
|
235
|
+
self.assertFalse(rule.preserve_host)
|
|
236
|
+
self.assertEqual(rule.hash_key, "x-user")
|
|
237
|
+
self.assertEqual(rule.target_count(), 2)
|
|
238
|
+
self.assertTrue(
|
|
239
|
+
rule.matches(
|
|
240
|
+
_request(
|
|
241
|
+
"/v2/users",
|
|
242
|
+
host="api.example.test",
|
|
243
|
+
headers={"x-env": "PROD", "x-role": "superadmin"},
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def test_location_kinds_replace_each_other(self):
|
|
249
|
+
builder = ProxyI().route().location("/a").location("/b", exact=True).upstream("http://127.0.0.1:9402")
|
|
250
|
+
rule = builder.build()
|
|
251
|
+
self.assertEqual((rule.path, rule.path_prefix), ("/b", ""))
|
|
252
|
+
builder.path_contains("/c")
|
|
253
|
+
self.assertEqual((rule.path, rule.path_contains), ("", ("/c",)))
|
|
254
|
+
builder.path_prefix("/d")
|
|
255
|
+
self.assertEqual((rule.path_prefix, rule.path_contains), ("/d", ()))
|
|
256
|
+
builder.vhost("one.test", "two.test").default()
|
|
257
|
+
self.assertEqual(rule.hosts, ("one.test", "two.test"))
|
|
258
|
+
self.assertTrue(rule.default)
|
|
259
|
+
|
|
260
|
+
def test_invalid_builders_and_rules_are_rejected(self):
|
|
261
|
+
proxy = ProxyI()
|
|
262
|
+
with self.assertRaisesRegex(ValueError, "at least one upstream"):
|
|
263
|
+
proxy.route(name="empty").build()
|
|
264
|
+
with self.assertRaisesRegex(TypeError, "unexpected route options: colour"):
|
|
265
|
+
proxy.route(colour="blue")
|
|
266
|
+
with self.assertRaises(TypeError):
|
|
267
|
+
proxy.add_rule({"name": "not a rule"})
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class TestDispatch(unittest.TestCase):
|
|
271
|
+
def setUp(self):
|
|
272
|
+
self.proxy = ProxyI(name="edge")
|
|
273
|
+
self.addCleanup(self.proxy.close)
|
|
274
|
+
|
|
275
|
+
def test_unmatched_requests_are_404_and_counted(self):
|
|
276
|
+
self.proxy.route(hosts=("only.test",)).upstream("http://127.0.0.1:9500").build()
|
|
277
|
+
|
|
278
|
+
response = self.proxy.dispatch(_request("/x", host="other.test"))
|
|
279
|
+
|
|
280
|
+
self.assertEqual(response.status, 404)
|
|
281
|
+
self.assertEqual(self.proxy.metrics.snapshot()["status_counts"], {"404": 1})
|
|
282
|
+
|
|
283
|
+
def test_an_unreachable_upstream_is_502_and_marks_the_target(self):
|
|
284
|
+
rule = self.proxy.location("/").upstream(
|
|
285
|
+
f"http://127.0.0.1:{_closed_port()}", name="down", timeout_seconds=0.5
|
|
286
|
+
).build()
|
|
287
|
+
|
|
288
|
+
response = self.proxy.dispatch(_request("/x"))
|
|
289
|
+
|
|
290
|
+
self.assertEqual(response.status, 502)
|
|
291
|
+
snapshot = rule.targets[0].metrics.snapshot()
|
|
292
|
+
self.assertEqual(snapshot["errors_total"], 1)
|
|
293
|
+
self.assertEqual(snapshot["active_requests"], 0)
|
|
294
|
+
self.assertEqual(rule.metrics.snapshot()["errors_total"], 1)
|
|
295
|
+
|
|
296
|
+
def test_a_rule_whose_targets_are_all_disabled_is_502(self):
|
|
297
|
+
rule = self.proxy.location("/").upstream("http://127.0.0.1:9501").build()
|
|
298
|
+
rule.targets[0].enabled = False
|
|
299
|
+
response = self.proxy.dispatch(_request("/x"))
|
|
300
|
+
self.assertEqual(response.status, 502)
|
|
301
|
+
self.assertEqual(rule.metrics.snapshot()["last_error"], "no upstream target")
|
|
302
|
+
|
|
303
|
+
def test_built_in_endpoints_answer_get_only(self):
|
|
304
|
+
self.proxy.location("/").upstream("http://127.0.0.1:9502").build()
|
|
305
|
+
for path in (self.proxy.metrics_path, self.proxy.metrics_stream_path, self.proxy.dashboard_path):
|
|
306
|
+
with self.subTest(path=path):
|
|
307
|
+
response = self.proxy.dispatch(_request(path, method="POST"))
|
|
308
|
+
self.assertEqual(response.status, 405)
|
|
309
|
+
self.assertEqual(response.headers["Allow"], "GET")
|
|
310
|
+
|
|
311
|
+
snapshot = json.loads(self.proxy.dispatch(_request(self.proxy.metrics_path)).body)
|
|
312
|
+
self.assertEqual(snapshot["rules_total"], 1)
|
|
313
|
+
self.assertEqual(snapshot["targets_total"], 1)
|
|
314
|
+
self.assertIn(BALANCING_BEST, snapshot["supported_balancing"])
|
|
315
|
+
|
|
316
|
+
dashboard = self.proxy.dispatch(_request(self.proxy.dashboard_path))
|
|
317
|
+
self.assertIn(b"<html", dashboard.body.lower())
|
|
318
|
+
|
|
319
|
+
def test_the_metrics_stream_honours_its_point_limit(self):
|
|
320
|
+
response = self.proxy.dispatch(_request(self.proxy.metrics_stream_path, query="limit=2&interval=0"))
|
|
321
|
+
lines = list(response.stream)
|
|
322
|
+
self.assertEqual(len(lines), 2)
|
|
323
|
+
self.assertEqual(json.loads(lines[0])["name"], "edge")
|
|
324
|
+
self.assertIn("ndjson", response.headers["Content-Type"])
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class TestInstall(unittest.TestCase):
|
|
328
|
+
def test_install_registers_the_endpoints_on_the_app(self):
|
|
329
|
+
app = App()
|
|
330
|
+
self.addCleanup(app.close)
|
|
331
|
+
proxy = ProxyI(name="mounted")
|
|
332
|
+
proxy.install(app)
|
|
333
|
+
|
|
334
|
+
self.assertIs(app.proxyi, proxy)
|
|
335
|
+
metrics = app.dispatch(_request(proxy.metrics_path))
|
|
336
|
+
self.assertEqual(json.loads(metrics.body)["name"], "mounted")
|
|
337
|
+
stream = app.dispatch(_request(proxy.metrics_stream_path, query="limit=1&interval=0"))
|
|
338
|
+
self.assertEqual(len(list(stream.stream)), 1)
|
|
339
|
+
dashboard = app.dispatch(_request(proxy.dashboard_path))
|
|
340
|
+
self.assertEqual(dashboard.status, 200)
|
|
341
|
+
|
|
342
|
+
def test_install_without_an_app_just_returns_a_proxy(self):
|
|
343
|
+
proxy = install_proxyi(None, metrics_path="/m")
|
|
344
|
+
self.assertIsInstance(proxy, ProxyI)
|
|
345
|
+
self.assertEqual(proxy.metrics_path, "/m")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
if __name__ == "__main__":
|
|
349
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|