76일차 자료 추가 (답 포함): 실습 15개 · 의도 · 생활 속 예 · 실행 결과, LAB-A · 정답 워크플로우와 25단계 실행 기록, 교안 개요 30장 · 4단계 정리, 바이브 코딩 tretry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3f36605043
commit
bbf17e4696
141 files changed
+34072
No files matched your search
@@ -0,0 +1,60 @@
|
||||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""sleep() only moves the clock, so a test never waits and the waits can be checked."""
|
||||
def __init__(self):
|
||||
self.now = 0.0
|
||||
self.slept: list[float] = []
|
||||
|
||||
def sleep(self, seconds: float):
|
||||
self.slept.append(seconds)
|
||||
self.now += seconds
|
||||
|
||||
def __call__(self):
|
||||
return self.now
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock():
|
||||
return FakeClock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server():
|
||||
"""A local server whose answers come from a script: path -> list of (status, body, headers), one per call."""
|
||||
script: dict[str, list] = {}
|
||||
seen: list[dict] = []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def answer(self):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
seen.append({"path": self.path, "method": self.command, "headers": dict(self.headers),
|
||||
"body": self.rfile.read(length).decode() if length else ""})
|
||||
queue = script.get(self.path) or [(200, {"ok": True}, {})]
|
||||
status, body, headers = queue.pop(0) if len(queue) > 1 else queue[0]
|
||||
if status == "sleep":
|
||||
import time
|
||||
time.sleep(body)
|
||||
status, body, headers = 200, {"late": True}, {}
|
||||
data = json.dumps(body).encode()
|
||||
self.send_response(status)
|
||||
for key, value in headers.items():
|
||||
self.send_header(key, value)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
do_GET = do_POST = answer
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
yield f"http://127.0.0.1:{httpd.server_port}", script, seen
|
||||
httpd.shutdown()
|
||||
@@ -0,0 +1,29 @@
|
||||
"""R11: the screen ranks targets by failed tries and lists the tries of the chosen one."""
|
||||
from textual.widgets import DataTable
|
||||
|
||||
from tretry import log
|
||||
from tretry.app import LogApp
|
||||
from tretry.retry import Attempt, Result
|
||||
|
||||
|
||||
def make_log(tmp_path):
|
||||
path = tmp_path / "log.jsonl"
|
||||
log.write(path, "http", "POST http://x/tickets", Result(False, attempts=[Attempt(1, 0, 3, False, "timeout"), Attempt(2, 3.5, 3, False, "timeout")],
|
||||
stopped="tries", seconds=6.5), now=100)
|
||||
log.write(path, "command", "curl ok", Result(True, attempts=[Attempt(1, 0, 0.1, True)], stopped="success", seconds=0.1), now=101)
|
||||
return path
|
||||
|
||||
|
||||
async def test_tables(tmp_path):
|
||||
app = LogApp(make_log(tmp_path))
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
targets = app.query_one("#targets", DataTable)
|
||||
assert targets.row_count == 2 and targets.get_row_at(0)[0] == "POST http://x/tickets" # most failures first
|
||||
assert app.query_one("#tries", DataTable).row_count == 2
|
||||
await pilot.press("down")
|
||||
await pilot.pause()
|
||||
assert app.query_one("#tries", DataTable).row_count == 1
|
||||
await pilot.press("f")
|
||||
await pilot.pause()
|
||||
assert app.query_one("#tries", DataTable).row_count == 0
|
||||
@@ -0,0 +1,91 @@
|
||||
"""R1 · R3 · R4 · R5 · R7 · R8 · R9 · R10 through the command line (real commands, local fake server, temp log)."""
|
||||
import json
|
||||
import sys
|
||||
|
||||
from tretry import cli, log
|
||||
|
||||
PY = sys.executable
|
||||
|
||||
|
||||
def run(capsys, *argv):
|
||||
code = cli.main(list(argv))
|
||||
out = capsys.readouterr()
|
||||
return code, out.out, out.err
|
||||
|
||||
|
||||
def test_command_fails_then_fallback(tmp_path, capsys):
|
||||
logfile = tmp_path / "log.jsonl"
|
||||
code, out, err = run(capsys, "--tries", "2", "--backoff", "0", "--log", str(logfile), "--fallback", "echo cached",
|
||||
"--", PY, "-c", "import sys; sys.exit(3)")
|
||||
assert code == 0 and out.strip() == "cached" and "by the fallback" in err
|
||||
rows = log.read(logfile)
|
||||
assert [r["fallback"] for r in rows] == [False, False, True] and rows[0]["error"].startswith("exit 3")
|
||||
|
||||
|
||||
def test_command_timeout(tmp_path, capsys):
|
||||
code, _, err = run(capsys, "--tries", "1", "--timeout", "0.3", "--log", str(tmp_path / "l"), "--",
|
||||
PY, "-c", "import time; time.sleep(5)")
|
||||
assert code == 1 and "timeout 0.3s" in err
|
||||
|
||||
|
||||
def test_http_5xx_retried_4xx_not(server, tmp_path, capsys):
|
||||
base, script, seen = server
|
||||
script["/flaky"] = [(500, {"error": "x"}, {}), (201, {"ticket_id": "T-1"}, {})]
|
||||
script["/bad"] = [(400, {"error": "title is required"}, {})]
|
||||
logfile = str(tmp_path / "log.jsonl")
|
||||
code, out, _ = run(capsys, "http", "POST", base + "/flaky", "--body", "{}", "--backoff", "0", "--log", logfile)
|
||||
assert code == 0 and "T-1" in out and len(seen) == 2
|
||||
code, _, err = run(capsys, "http", "POST", base + "/bad", "--body", "{}", "--backoff", "0", "--log", logfile,
|
||||
"--fallback", "echo cached")
|
||||
assert code == 3 and len(seen) == 3 and "not-retryable" in err # one try, no fallback
|
||||
|
||||
|
||||
def test_http_same_idempotency_key_every_try(server, tmp_path, capsys):
|
||||
base, script, seen = server
|
||||
script["/tickets"] = [(500, {}, {}), (500, {}, {}), (201, {"ticket_id": "T-1"}, {})]
|
||||
code, _, _ = run(capsys, "http", "POST", base + "/tickets", "--body", '{"title": "t"}', "--idem-key", "auto",
|
||||
"--body-key", "key", "--backoff", "0", "--log", str(tmp_path / "l"))
|
||||
keys = {s["headers"]["Idempotency-Key"] for s in seen}
|
||||
bodies = {json.loads(s["body"])["key"] for s in seen}
|
||||
assert code == 0 and len(seen) == 3 and len(keys) == 1 and keys == bodies # R5
|
||||
|
||||
|
||||
def test_http_timeout_is_retried(server, tmp_path, capsys):
|
||||
base, script, seen = server
|
||||
script["/slow"] = [("sleep", 1.0, {}), (200, {"ok": True}, {})]
|
||||
code, _, err = run(capsys, "http", "GET", base + "/slow", "--timeout", "0.3", "--backoff", "0", "--log", str(tmp_path / "l"))
|
||||
assert code == 0 and "timeout" in err and len(seen) == 2
|
||||
|
||||
|
||||
def test_log_never_keeps_a_key(server, tmp_path, capsys):
|
||||
base, script, _ = server
|
||||
script["/x?api_key=secret123"] = [(503, {}, {})]
|
||||
logfile = tmp_path / "log.jsonl"
|
||||
run(capsys, "http", "GET", base + "/x?api_key=secret123", "--tries", "1", "--header", "Authorization: Bearer sk-abcdefghijklmnop",
|
||||
"--log", str(logfile))
|
||||
text = logfile.read_text()
|
||||
assert "secret123" not in text and "sk-abcdefghijklmnop" not in text and "***" in text # R7
|
||||
|
||||
|
||||
def test_json_output_and_exit_code(tmp_path, capsys):
|
||||
code, out, _ = run(capsys, "--tries", "2", "--backoff", "0", "--json", "--log", str(tmp_path / "l"), "--", PY, "-c", "raise SystemExit(1)")
|
||||
data = json.loads(out)
|
||||
assert code == 1 and data["exit_code"] == 1 and data["stopped"] == "tries" and len(data["attempts"]) == 2 # R8
|
||||
|
||||
|
||||
def test_stats_and_breaker(tmp_path, capsys):
|
||||
logfile = str(tmp_path / "log.jsonl")
|
||||
fail = ["--backoff", "0", "--log", logfile, "--", PY, "-c", "raise SystemExit(1)"]
|
||||
run(capsys, "--tries", "3", *fail)
|
||||
run(capsys, "--tries", "2", *fail) # 5 failed tries in a row → breaker opens
|
||||
code, out, err = run(capsys, "--tries", "3", "--fallback", "echo cached", *fail)
|
||||
assert code == 0 and out.strip() == "cached" and "breaker open" in err # R10: no try, straight to fallback
|
||||
code, out, _ = run(capsys, "stats", "--log", logfile, "--json")
|
||||
row = json.loads(out)[0]
|
||||
assert row["runs"] == 3 and row["failed_attempts"] == 5 and row["fallbacks"] == 1 and row["success_rate"] == 0.333 # R9
|
||||
|
||||
|
||||
def test_usage_errors(capsys):
|
||||
assert run(capsys, "echo", "hi")[0] == 2 # no --
|
||||
assert run(capsys, "http", "POST", "http://x", "--body", "{bad")[0] == 2
|
||||
assert run(capsys, "--tries", "0", "--", "true")[0] == 2
|
||||
@@ -0,0 +1,67 @@
|
||||
"""R1 · R2 · R3 · R6: the engine with fake attempts and a fake clock (no waiting)."""
|
||||
from tretry.retry import Outcome, Policy, run_with_retry
|
||||
|
||||
|
||||
def failing(times, retryable=True):
|
||||
calls = []
|
||||
|
||||
def attempt(timeout):
|
||||
calls.append(timeout)
|
||||
if len(calls) <= times:
|
||||
return Outcome(False, retryable, f"boom {len(calls)}")
|
||||
return Outcome(True, output="done")
|
||||
return attempt, calls
|
||||
|
||||
|
||||
def test_success_after_two_failures_waits_with_backoff(clock):
|
||||
attempt, calls = failing(2)
|
||||
result = run_with_retry(attempt, Policy(tries=4, backoff=0.5), sleep=clock.sleep, clock=clock)
|
||||
assert result.ok and result.output == "done" and len(calls) == 3
|
||||
assert clock.slept == [0.5, 1.0] # R1: 0.5 → 1.0 (doubles)
|
||||
|
||||
|
||||
def test_wait_has_a_cap_and_jitter_only_shortens():
|
||||
policy = Policy(tries=10, backoff=1, max_wait=4, jitter=0.5)
|
||||
assert [policy.wait(n, rand=lambda: 0.0) for n in range(1, 6)] == [1, 2, 4, 4, 4] # R1: cap
|
||||
assert policy.wait(3, rand=lambda: 1.0) == 2.0 # R2: up to 50% shorter
|
||||
|
||||
|
||||
def test_all_fail_then_fallback(clock):
|
||||
attempt, _ = failing(9)
|
||||
result = run_with_retry(attempt, Policy(tries=3), fallback=lambda t: Outcome(True, output="cached"),
|
||||
sleep=clock.sleep, clock=clock)
|
||||
assert result.ok and result.used_fallback and result.output == "cached" # R3
|
||||
assert [a.fallback for a in result.attempts] == [False, False, False, True]
|
||||
|
||||
|
||||
def test_not_retryable_stops_at_once_and_skips_fallback(clock):
|
||||
attempt, calls = failing(9, retryable=False)
|
||||
result = run_with_retry(attempt, Policy(tries=5), fallback=lambda t: Outcome(True, output="cached"),
|
||||
sleep=clock.sleep, clock=clock)
|
||||
assert not result.ok and len(calls) == 1 and result.stopped == "not-retryable" and not result.used_fallback
|
||||
|
||||
|
||||
def test_deadline_stops_before_a_wait_that_cannot_fit(clock):
|
||||
def slow(timeout):
|
||||
clock.now += 3 # every try takes 3 s
|
||||
return Outcome(False, True, "timeout")
|
||||
result = run_with_retry(slow, Policy(tries=5, backoff=1, deadline=7), sleep=clock.sleep, clock=clock)
|
||||
assert result.stopped == "deadline" and len(result.attempts) == 2 # R6: 3 + 1 + 3 = 7 → no third try
|
||||
assert result.seconds <= 7
|
||||
|
||||
|
||||
def test_deadline_shortens_the_last_timeout(clock):
|
||||
seen = []
|
||||
|
||||
def attempt(timeout):
|
||||
seen.append(timeout)
|
||||
clock.now += timeout
|
||||
return Outcome(False, True, "timeout")
|
||||
run_with_retry(attempt, Policy(tries=3, backoff=0.5, deadline=5), per_try_timeout=3, sleep=clock.sleep, clock=clock)
|
||||
assert seen == [3, 1.5] # 3 + 0.5 wait → 1.5 s left for try 2
|
||||
|
||||
|
||||
def test_retry_after_is_respected_up_to_the_cap(clock):
|
||||
answers = [Outcome(False, True, "HTTP 429", status=429, retry_after=3), Outcome(True, output="ok")]
|
||||
result = run_with_retry(lambda t: answers.pop(0), Policy(tries=3, backoff=0.5, max_wait=8), sleep=clock.sleep, clock=clock)
|
||||
assert result.ok and clock.slept == [3] # R4: the server's 3 s, not our 0.5 s
|
||||
Reference in new issue
Block a user