Files
devplacepy/tests/unit/services/nodes/fabric.py
T
retoor 6f80a820da
DevPlace CI / test (push) Failing after 3m13s
Zo doen wijdat in oost
2026-10-16 07:20:44 +02:00

232 lines
7.8 KiB
Python

# retoor <retoor@molodetz.nl>
import json
from datetime import datetime, timezone
import pytest
from devplacepy.database import get_table, set_setting
from devplacepy.database import nodes as store
from devplacepy.services.nodes.broker import BrokerResult, JobBroker
from devplacepy.services.nodes.cache import ResultCache
from devplacepy.services.nodes.fabric import (
FabricQuotaExceeded,
FabricRefused,
FabricUnavailable,
NodeFabric,
)
from devplacepy.services.nodes.spec import SPEC
from devplacepy.utils import generate_uid
from tests.conftest import run_async
LIMITS = SPEC.limits
URL = "https://www.reddit.com/r/python.json"
class ScriptedBroker(JobBroker):
def __init__(self, outcomes):
self.outcomes = list(outcomes)
self.enqueued = 0
def enqueue(self, caller_kind, caller_id, target, mode):
self.enqueued += 1
return f"uid-{self.enqueued}"
async def wait(self, uid, timeout):
outcome = self.outcomes.pop(0)
if outcome == "ok":
return BrokerResult(uid, "done", 200, {"content-type": "text/plain"}, b"payload", False, "")
return BrokerResult(uid, "failed", 0, {}, b"", False, outcome)
def heartbeat(connected):
table = get_table("service_state")
record = {
"name": "nodes",
"status": "running",
"heartbeat": datetime.now(timezone.utc).isoformat(),
"metrics": json.dumps({"stats": [{"label": "connected", "value": connected}]}),
}
existing = table.find_one(name="nodes")
if existing:
table.update({**record, "id": existing["id"]}, ["id"])
else:
table.insert(record)
@pytest.fixture
def online(local_db):
set_setting("nodes_enabled", "1")
set_setting("nodes_daily_quota_user", "1000")
set_setting("nodes_daily_quota_guest", "0")
heartbeat(3)
yield
set_setting("nodes_enabled", "0")
get_table("service_state").delete(name="nodes")
def fetch(fabric, **kwargs):
kwargs.setdefault("caller_kind", "user")
kwargs.setdefault("caller_id", generate_uid())
return run_async(fabric.fetch(URL, **kwargs))
def test_a_node_fetch_returns_the_body_and_caches_it(online):
broker = ScriptedBroker(["ok"])
fabric = NodeFabric(broker=broker)
first = fetch(fabric, caller_id="c")
assert (first.status, first.body, first.via) == (200, b"payload", "fetch")
second = fetch(fabric, caller_id="c")
assert second.via == "cache" and second.body == b"payload" and second.elapsed_ms == 0
assert broker.enqueued == 1
def test_use_cache_false_always_asks_a_node(online):
broker = ScriptedBroker(["ok", "ok"])
fabric = NodeFabric(broker=broker)
fetch(fabric, caller_id="c", use_cache=False)
fetch(fabric, caller_id="c", use_cache=False)
assert broker.enqueued == 2
def test_policy_refusals_surface_with_their_code(online):
fabric = NodeFabric(broker=ScriptedBroker([]))
for url, code in (("https://example.com/", "host_not_allowed"), ("https://reddit.com:22/", "port_not_allowed")):
with pytest.raises(FabricRefused) as info:
run_async(fabric.fetch(url, caller_kind="user", caller_id="c"))
assert info.value.code == code
def test_the_kill_switch_and_missing_nodes_are_unavailable(online):
fabric = NodeFabric(broker=ScriptedBroker([]))
heartbeat(0)
with pytest.raises(FabricUnavailable) as info:
fetch(fabric)
assert info.value.code == "no_nodes"
heartbeat(2)
set_setting("nodes_enabled", "0")
with pytest.raises(FabricUnavailable) as info:
fetch(fabric)
assert info.value.code == "kill_switch"
def test_a_stale_heartbeat_counts_as_no_nodes(online):
table = get_table("service_state")
row = table.find_one(name="nodes")
table.update({"id": row["id"], "heartbeat": "2000-01-01T00:00:00+00:00"}, ["id"])
assert NodeFabric(broker=ScriptedBroker([])).available().nodes_online == 0
def test_no_service_row_means_no_nodes(online):
get_table("service_state").delete(name="nodes")
assert NodeFabric().available().nodes_online == 0
def test_quota_is_enforced_before_any_node_is_touched(online):
set_setting("nodes_daily_quota_user", "2")
broker = ScriptedBroker(["ok"] * 5)
fabric = NodeFabric(broker=broker)
caller = generate_uid()
for _ in range(2):
fetch(fabric, caller_id=caller, use_cache=False)
with pytest.raises(FabricQuotaExceeded):
fetch(fabric, caller_id=caller, use_cache=False)
assert broker.enqueued == 2
def test_guests_have_no_quota_by_default(online):
broker = ScriptedBroker(["ok"])
with pytest.raises(FabricQuotaExceeded):
fetch(NodeFabric(broker=broker), caller_kind="guest", caller_id="g")
assert broker.enqueued == 0
def test_a_cache_hit_costs_no_quota(online):
set_setting("nodes_daily_quota_user", "1")
fabric = NodeFabric(broker=ScriptedBroker(["ok"]))
caller = generate_uid()
fetch(fabric, caller_id=caller)
for _ in range(5):
assert fetch(fabric, caller_id=caller).via == "cache"
def test_a_retryable_failure_is_retried_once_on_another_node(online):
broker = ScriptedBroker(["node_disconnected", "ok"])
result = fetch(NodeFabric(broker=broker))
assert result.body == b"payload" and broker.enqueued == 2
def test_the_retry_does_not_spend_a_second_quota_unit(online):
set_setting("nodes_daily_quota_user", "1")
caller = generate_uid()
fetch(NodeFabric(broker=ScriptedBroker(["timeout", "ok"])), caller_id=caller)
assert store.caller_requests_today("user", caller) == 1
def test_retries_stop_after_the_attempt_cap(online):
broker = ScriptedBroker(["timeout"] * 5)
with pytest.raises(FabricUnavailable) as info:
fetch(NodeFabric(broker=broker))
assert info.value.code == "timeout" and broker.enqueued == 2
def test_a_non_retryable_failure_is_not_retried(online):
broker = ScriptedBroker(["bad_status", "ok"])
with pytest.raises(FabricUnavailable):
fetch(NodeFabric(broker=broker))
assert broker.enqueued == 1
def test_a_full_queue_is_a_clean_unavailable(online):
class Full(ScriptedBroker):
def enqueue(self, *args):
raise store.QueueFull("full")
with pytest.raises(FabricUnavailable) as info:
fetch(NodeFabric(broker=Full([])))
assert info.value.code == "queue_full"
def test_the_wait_is_capped_at_the_spec_maximum(online):
seen = []
class Spy(ScriptedBroker):
async def wait(self, uid, timeout):
seen.append(timeout)
return await super().wait(uid, timeout)
fetch(NodeFabric(broker=Spy(["ok"])), timeout=10**6)
assert seen[0] <= LIMITS.max_wait_seconds
def test_the_cache_ttl_follows_the_admin_setting_up_to_the_ceiling(online):
cache = ResultCache()
fabric = NodeFabric(broker=ScriptedBroker(["ok"]), cache=cache)
set_setting("nodes_cache_ttl_seconds", "0")
fetch(fabric, caller_id="c")
assert len(cache) == 0
set_setting("nodes_cache_ttl_seconds", str(10**9))
fabric._sync_cache_ttl()
assert cache.ttl_seconds == LIMITS.cache_ttl_seconds
set_setting("nodes_cache_ttl_seconds", str(LIMITS.cache_ttl_seconds))
def test_can_serve_needs_policy_switch_and_a_node(online):
fabric = NodeFabric(broker=ScriptedBroker([]))
assert fabric.can_serve(URL)
assert not fabric.can_serve("https://example.com/")
assert not fabric.can_serve(URL, "POST")
heartbeat(0)
assert not fabric.can_serve(URL)
def test_the_real_broker_times_out_a_request_nobody_picks_up(online):
get_table("node_requests").delete()
fabric = NodeFabric()
with pytest.raises(FabricUnavailable) as info:
fetch(fabric, timeout=0.3)
assert info.value.code in ("caller_timeout", "failed")
rows = list(get_table("node_requests").find())
assert rows and all(row["status"] == "failed" for row in rows)
get_table("node_requests").delete()