feat(forge_client): port to FULL Go — dichotomy wave
This commit is contained in:
1 parent
04583cd26a
commit
797bb63f3c
8 files changed
+3950
No files matched your search
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Truth generator for the forge_client dichotomy port (#2479 tranche, GO).
|
||||
|
||||
Runs the LIVE Python oracle (src/rune/forge_client.py) with the httpx
|
||||
transport fully scripted, exercises every public function and every branch,
|
||||
and freezes inputs + expected outputs to src/go/forge_client/truth.json
|
||||
(consumed VERBATIM by the Go battery).
|
||||
|
||||
No clock, no store, no env leakage: every case is hermetic — the transport
|
||||
is a scripted fake (never a real socket), sleep is a recording fake, and
|
||||
os.environ is snapshotted/restored around every selection case.
|
||||
|
||||
Scripted entry shapes:
|
||||
{"status": N, "payload": {...}} → FakeResponse(N, payload)
|
||||
{"exc": "connect" | "connect_timeout" | "read_timeout"} → scripted
|
||||
httpx exception (connect-phase vs non-connect transport, the
|
||||
NOISE-vs-SIGNAL axis of _request).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
OUT = REPO / "src" / "go" / "forge_client" / "truth.json"
|
||||
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
|
||||
import rune.forge_client as fc # noqa: E402
|
||||
|
||||
EXC = {
|
||||
"connect": httpx.ConnectError,
|
||||
"connect_timeout": httpx.ConnectTimeout,
|
||||
"read_timeout": httpx.ReadTimeout,
|
||||
}
|
||||
|
||||
rows = []
|
||||
coverage = set()
|
||||
|
||||
|
||||
def add(name, op, inp, want, covered):
|
||||
rows.append({"name": name, "op": op, "input": inp, "want": want})
|
||||
coverage.update(covered)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status=200, payload=None):
|
||||
self.status_code = status
|
||||
self._payload = payload if payload is not None else {}
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise httpx.HTTPStatusError(
|
||||
f"error {self.status_code}",
|
||||
request=httpx.Request("GET", "https://x"),
|
||||
response=httpx.Response(self.status_code),
|
||||
)
|
||||
|
||||
|
||||
def make_fake(method, script, rec):
|
||||
pending = list(script)
|
||||
|
||||
def fake(url, **kw):
|
||||
rec["requests"].append(
|
||||
{
|
||||
"method": method,
|
||||
"url": url,
|
||||
"headers": kw.get("headers"),
|
||||
"json": kw.get("json"),
|
||||
"timeout": kw.get("timeout"),
|
||||
}
|
||||
)
|
||||
if not pending:
|
||||
raise AssertionError(f"script exhausted at request {len(rec['requests'])}")
|
||||
entry = pending.pop(0)
|
||||
if "exc" in entry:
|
||||
raise EXC[entry["exc"]]("scripted")
|
||||
return FakeResponse(entry.get("status", 200), entry.get("payload"))
|
||||
|
||||
return fake
|
||||
|
||||
|
||||
def blank_rec():
|
||||
return {"requests": [], "sleeps": [], "error": None}
|
||||
|
||||
|
||||
def finish_rec(rec, result):
|
||||
return {
|
||||
"result": result,
|
||||
"requests": rec["requests"],
|
||||
"sleeps": rec["sleeps"],
|
||||
"error": rec["error"],
|
||||
}
|
||||
|
||||
|
||||
def call_case(name, method_name, script, kwargs, *, url="https://forge.example.com",
|
||||
token="tok", owner="o", repo="r", covered=()):
|
||||
rec = blank_rec()
|
||||
adapter = fc.GiteaForgejoAdapter(url, token, owner=owner, repo=repo)
|
||||
with patch("rune.forge_client.httpx.get", make_fake("GET", script, rec)), patch(
|
||||
"rune.forge_client.httpx.post", make_fake("POST", script, rec)
|
||||
), patch("rune.forge_client.time.sleep", rec["sleeps"].append):
|
||||
try:
|
||||
result = getattr(adapter, method_name)(**kwargs)
|
||||
except Exception as e: # noqa: BLE001 — the kind IS the information
|
||||
rec["error"] = f"httpx.{type(e).__name__}"
|
||||
result = None
|
||||
add(
|
||||
name,
|
||||
"call",
|
||||
{
|
||||
"method": method_name,
|
||||
"url": url,
|
||||
"token": token,
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"script": script,
|
||||
"kwargs": kwargs,
|
||||
},
|
||||
finish_rec(rec, result),
|
||||
("GiteaForgejoAdapter." + method_name,) + covered,
|
||||
)
|
||||
|
||||
|
||||
def request_case(name, method, script, *, headers=None, json_body=None, timeout=30,
|
||||
retries=2, backoff=0.5, covered=()):
|
||||
rec = blank_rec()
|
||||
with patch("rune.forge_client.httpx.get", make_fake("GET", script, rec)), patch(
|
||||
"rune.forge_client.httpx.post", make_fake("POST", script, rec)
|
||||
):
|
||||
try:
|
||||
result = fc._request(
|
||||
method,
|
||||
"https://x/api",
|
||||
headers=headers,
|
||||
json=json_body,
|
||||
timeout=timeout,
|
||||
retries=retries,
|
||||
backoff=backoff,
|
||||
sleep=rec["sleeps"].append,
|
||||
).json()
|
||||
except Exception as e: # noqa: BLE001
|
||||
rec["error"] = f"httpx.{type(e).__name__}"
|
||||
result = None
|
||||
add(
|
||||
name,
|
||||
"request",
|
||||
{
|
||||
"method": method,
|
||||
"url": "https://x/api",
|
||||
"headers": headers,
|
||||
"json": json_body,
|
||||
"timeout": timeout,
|
||||
"retries": retries,
|
||||
"backoff": backoff,
|
||||
"script": script,
|
||||
},
|
||||
finish_rec(rec, result),
|
||||
("_request",) + covered,
|
||||
)
|
||||
|
||||
|
||||
def select_case(name, config, env, covered=()):
|
||||
saved = {k: os.environ.get(k) for k in
|
||||
("FORGE_URL", "FORGE_TOKEN", "FORGE_OWNER", "FORGE_REPO")}
|
||||
try:
|
||||
for k in ("FORGE_URL", "FORGE_TOKEN", "FORGE_OWNER", "FORGE_REPO"):
|
||||
os.environ.pop(k, None)
|
||||
os.environ.update({k: v for k, v in env.items() if v is not None})
|
||||
client = fc.create_forge_client(config)
|
||||
if isinstance(client, fc.NullAdapter):
|
||||
want = {"adapter": "NullAdapter", "base_url": None, "token": None}
|
||||
else:
|
||||
want = {
|
||||
"adapter": "GiteaForgejoAdapter",
|
||||
"base_url": client._base_url,
|
||||
"token": client._token,
|
||||
}
|
||||
finally:
|
||||
for k, v in saved.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
add(name, "select", {"config": config, "env": env}, want,
|
||||
("create_forge_client",) + covered)
|
||||
|
||||
|
||||
def null_case(name, method_name, args):
|
||||
client = fc.NullAdapter()
|
||||
result = getattr(client, method_name)(*args)
|
||||
add(name, "null", {"method": method_name, "args": args}, result,
|
||||
("NullAdapter." + method_name,))
|
||||
|
||||
|
||||
# ── create_forge_client: every selection branch ─────────────────────────────
|
||||
select_case("select:none", None, {})
|
||||
select_case("select:env-configured", {}, {"FORGE_URL": "https://forge.example.com",
|
||||
"FORGE_TOKEN": "test_token"})
|
||||
select_case("select:config-keys",
|
||||
{"forge.url": "https://cfg.example.com", "forge.token": "cfg_tok",
|
||||
"forge.owner": "cfg_owner", "forge.repo": "cfg_repo"}, {})
|
||||
select_case("select:config-beats-env",
|
||||
{"forge.url": "https://cfg.example.com", "forge.token": "cfg_tok"},
|
||||
{"FORGE_URL": "https://env.example.com", "FORGE_TOKEN": "env_tok"})
|
||||
select_case("select:owner-repo-defaults", {},
|
||||
{"FORGE_URL": "https://forge.example.com", "FORGE_TOKEN": "test_token"})
|
||||
select_case("select:owner-repo-env", {},
|
||||
{"FORGE_URL": "https://forge.example.com", "FORGE_TOKEN": "test_token",
|
||||
"FORGE_OWNER": "env_owner", "FORGE_REPO": "env_repo"})
|
||||
select_case("select:trailing-slash-strip", {},
|
||||
{"FORGE_URL": "https://forge.example.com///", "FORGE_TOKEN": "test_token"})
|
||||
select_case("select:empty-cfg-url-falls-to-env",
|
||||
{"forge.url": "", "forge.token": ""},
|
||||
{"FORGE_URL": "https://forge.example.com", "FORGE_TOKEN": "test_token"})
|
||||
select_case("select:cfg-url-missing-token", {"forge.url": "https://cfg.example.com"}, {})
|
||||
select_case("select:cfg-token-missing-url", {"forge.token": "cfg_tok"}, {})
|
||||
select_case("select:owner-empty-env", {},
|
||||
{"FORGE_URL": "https://forge.example.com", "FORGE_TOKEN": "test_token",
|
||||
"FORGE_OWNER": ""})
|
||||
|
||||
# ── NullAdapter: every no-op shape ──────────────────────────────────────────
|
||||
null_case("null:create-issue", "create_issue", ["t", "b"])
|
||||
null_case("null:add-comment", "add_comment", [3, "c"])
|
||||
null_case("null:get-issue-state", "get_issue_state", [7])
|
||||
null_case("null:list-open-issues", "list_open_issues", [])
|
||||
null_case("null:search-issues", "search_issues", ["q"])
|
||||
|
||||
# ── GiteaForgejoAdapter: every method + happy paths ─────────────────────────
|
||||
call_case("call:create-issue", "create_issue",
|
||||
[{"status": 200, "payload": {"number": 42, "state": "open"}}],
|
||||
{"title": "Test", "body": "Body"})
|
||||
call_case("call:create-issue-201", "create_issue",
|
||||
[{"status": 201, "payload": {"number": 1, "title": "t"}}],
|
||||
{"title": "t", "body": "b"})
|
||||
call_case("call:create-issue-4xx", "create_issue",
|
||||
[{"status": 422, "payload": {}}], {"title": "dup", "body": "b"})
|
||||
call_case("call:add-comment", "add_comment",
|
||||
[{"status": 200, "payload": {"id": 9, "body": "c"}}],
|
||||
{"issue_number": 5, "body": "c"})
|
||||
call_case("call:get-issue-state", "get_issue_state",
|
||||
[{"status": 200, "payload": {"number": 9, "state": "open"}}],
|
||||
{"issue_number": 9})
|
||||
call_case("call:list-open-issues", "list_open_issues",
|
||||
[{"status": 200, "payload": [{"number": 1}, {"number": 2}]}], {})
|
||||
call_case("call:search-single-page", "search_issues",
|
||||
[{"status": 200, "payload": [{"number": 3, "title": "[auto-failure:cron:Connection]"}]}],
|
||||
{"query": "[auto-failure:cron:Connection]"}, covered=("GiteaForgejoAdapter.search_issues.pagination",))
|
||||
call_case("call:search-paginates", "search_issues",
|
||||
[{"status": 200, "payload": [{"number": n, "title": f"issue {n}"} for n in range(50)]},
|
||||
{"status": 200, "payload": [{"number": 100, "title": "[auto-failure:cron:Connection] cron"}]}],
|
||||
{"query": "[auto-failure:cron:Connection]"},
|
||||
covered=("GiteaForgejoAdapter.search_issues.pagination",))
|
||||
call_case("call:search-filters-title-body", "search_issues",
|
||||
[{"status": 200, "payload": [
|
||||
{"number": 1, "title": "[auto-failure:cron:Connection] cron", "body": "x"},
|
||||
{"number": 2, "title": "plain title", "body": "quoted [auto-failure:cron:Connection] in body"},
|
||||
{"number": 3, "title": "mention of connection only", "body": "unrelated"}]}],
|
||||
{"query": "[auto-failure:cron:Connection]"},
|
||||
covered=("GiteaForgejoAdapter.search_issues.client-filter",))
|
||||
call_case("call:search-no-hits", "search_issues",
|
||||
[{"status": 200, "payload": [{"number": 1, "title": "unrelated"}]}],
|
||||
{"query": "[auto-failure:cron:Connection]"})
|
||||
call_case("call:search-exact-50-then-empty", "search_issues",
|
||||
[{"status": 200, "payload": [{"number": n, "title": "noise"} for n in range(50)]},
|
||||
{"status": 200, "payload": []}],
|
||||
{"query": "[auto-failure:cron:Connection]"},
|
||||
covered=("GiteaForgejoAdapter.search_issues.pagination",))
|
||||
call_case("call:create-issue-retry-connect", "create_issue",
|
||||
[{"exc": "connect"}, {"status": 200, "payload": {"number": 7}}],
|
||||
{"title": "t", "body": "b"}, covered=("_request",))
|
||||
call_case("call:get-issue-state-retry-5xx", "get_issue_state",
|
||||
[{"status": 502}, {"status": 200, "payload": {"number": 9, "state": "open"}}],
|
||||
{"issue_number": 9}, covered=("_request",))
|
||||
call_case("call:get-issue-state-4xx", "get_issue_state",
|
||||
[{"status": 404, "payload": {}}], {"issue_number": 404}, covered=("_request",))
|
||||
|
||||
# ── _request: every retry branch (noise vs signal) ──────────────────────────
|
||||
request_case("request:get-ok", "GET", [{"status": 200, "payload": {"ok": True}}])
|
||||
request_case("request:get-retries-connect", "GET",
|
||||
[{"exc": "connect"}, {"exc": "connect"},
|
||||
{"status": 200, "payload": {"ok": True}}])
|
||||
request_case("request:get-connect-exhaust", "GET",
|
||||
[{"exc": "connect"}, {"exc": "connect"}, {"exc": "connect"}])
|
||||
request_case("request:get-4xx-no-retry", "GET", [{"status": 422}])
|
||||
request_case("request:get-5xx-retry-then-ok", "GET",
|
||||
[{"status": 502}, {"status": 503}, {"status": 200, "payload": {"n": 3}}])
|
||||
request_case("request:get-5xx-exhaust", "GET", [{"status": 502}, {"status": 503}, {"status": 504}])
|
||||
request_case("request:get-read-timeout-retries", "GET",
|
||||
[{"exc": "read_timeout"}, {"status": 200, "payload": {"ok": True}}])
|
||||
request_case("request:get-read-timeout-exhaust", "GET",
|
||||
[{"exc": "read_timeout"}, {"exc": "read_timeout"}, {"exc": "read_timeout"}])
|
||||
request_case("request:post-ok", "POST", [{"status": 200, "payload": {"a": 1}}],
|
||||
json_body={"a": 1})
|
||||
request_case("request:post-connect-retry", "POST",
|
||||
[{"exc": "connect_timeout"}, {"status": 201, "payload": {"number": 7}}],
|
||||
json_body={"a": 1})
|
||||
request_case("request:post-connect-exhaust", "POST",
|
||||
[{"exc": "connect"}, {"exc": "connect"}, {"exc": "connect"}],
|
||||
json_body={"a": 1})
|
||||
request_case("request:post-read-timeout-no-retry", "POST",
|
||||
[{"exc": "read_timeout"}], json_body={"a": 1})
|
||||
request_case("request:post-5xx-no-retry", "POST", [{"status": 502}], json_body={"a": 1})
|
||||
request_case("request:post-4xx-no-retry", "POST", [{"status": 422}], json_body={"a": 1})
|
||||
request_case("request:backoff-exponential", "GET",
|
||||
[{"exc": "connect"}, {"exc": "connect"}, {"status": 200}])
|
||||
request_case("request:custom-retries-backoff", "GET",
|
||||
[{"exc": "connect"}, {"status": 200}], retries=1, backoff=0.25)
|
||||
request_case("request:timeout-passthrough", "GET",
|
||||
[{"status": 200}], timeout=17)
|
||||
request_case("request:headers-json-capture", "POST",
|
||||
[{"status": 200, "payload": {}}],
|
||||
headers={"Authorization": "token t", "Content-Type": "application/json"},
|
||||
json_body={"title": "t", "body": "b"})
|
||||
request_case("request:lowercase-method", "post",
|
||||
[{"status": 200, "payload": {"made": "it"}}], json_body={"k": "v"})
|
||||
|
||||
# ── freeze ───────────────────────────────────────────────────────────────────
|
||||
EXPECTED = {
|
||||
"create_forge_client", "_request",
|
||||
"NullAdapter.create_issue", "NullAdapter.add_comment", "NullAdapter.get_issue_state",
|
||||
"NullAdapter.list_open_issues", "NullAdapter.search_issues",
|
||||
"GiteaForgejoAdapter.create_issue", "GiteaForgejoAdapter.add_comment",
|
||||
"GiteaForgejoAdapter.get_issue_state", "GiteaForgejoAdapter.list_open_issues",
|
||||
"GiteaForgejoAdapter.search_issues",
|
||||
}
|
||||
missing = EXPECTED - coverage
|
||||
if missing:
|
||||
sys.exit(f"COVERAGE GAP — public functions not exercised: {sorted(missing)}")
|
||||
|
||||
doc = {
|
||||
"frozen_ts": "2026-10-12T02:17:44Z",
|
||||
"oracle": "src/rune/forge_client.py",
|
||||
"rows": rows,
|
||||
}
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
print(f"frozen {len(rows)} rows → {OUT}")
|
||||
print("covered:", ", ".join(sorted(coverage)))
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Open the #2479 dichotomy-wave construction contract for forge_client (GO port).
|
||||
Full lifecycle walk in ONE process (shared-store clobber hazard)."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from rune.construction_interface import ConstructionInterface
|
||||
|
||||
iface = ConstructionInterface(builder="factory-wave")
|
||||
|
||||
r = iface.submit_need(
|
||||
"Port src/rune/forge_client.py to FULL Go (dichotomy factory, issue "
|
||||
"#2479 tranche): name-preserving port of _request (noise-vs-signal "
|
||||
"retry semantics), NullAdapter, GiteaForgejoAdapter and "
|
||||
"create_forge_client with HTTP/sleep/env behind injectable seams, "
|
||||
"frozen-truth battery generated against the live Python oracle, live "
|
||||
"parity 100%, Phase B language-advantage pass.",
|
||||
origin="builder",
|
||||
)
|
||||
print("submit_need:", json.dumps(r)[:400])
|
||||
if not r.get("success"):
|
||||
print("NEED FAILED — aborting")
|
||||
sys.exit(1)
|
||||
cid = r["ids"]["contract_id"]
|
||||
print("contract_id:", cid)
|
||||
|
||||
for phase in ("approved", "building", "verifying"):
|
||||
t = iface.transition(cid, phase)
|
||||
print(f"transition->{phase}:", json.dumps(t)[:300])
|
||||
if not t.get("success"):
|
||||
sys.exit(1)
|
||||
|
||||
print("CONTRACT", cid, "IN VERIFYING (close happens at delivery with SHAs)")
|
||||
@@ -0,0 +1,484 @@
|
||||
// Package main — forge_client: the FULL Go port of src/rune/forge_client.py
|
||||
// (dichotomy wave #2479 tranche).
|
||||
//
|
||||
// Name-preserving 1:1 port: _request (retry where failure is NOISE, never
|
||||
// where failure is the SIGNAL), ForgeClient protocol, NullAdapter,
|
||||
// GiteaForgejoAdapter, create_forge_client. All IO sits behind injectable
|
||||
// seams so the frozen-truth battery can script the transport byte-for-byte:
|
||||
//
|
||||
// - transport: Sender interface (default HTTPXSender over net/http)
|
||||
// - sleep: RequestOptions.Sleep (default time.Sleep)
|
||||
// - env: LookupEnv var (default os.LookupEnv)
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Defaults (mirroring the Python signature defaults) ──────────────────────
|
||||
|
||||
const (
|
||||
DefaultTimeout = 30 * time.Second
|
||||
DefaultRetries = 2
|
||||
DefaultBackoff = 500 * time.Millisecond
|
||||
SearchPageSize = 50
|
||||
)
|
||||
|
||||
// ── Seams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// Response — the httpx.Response surface the port needs: status + JSON body.
|
||||
type Response struct {
|
||||
StatusCode int
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// JSON decodes the body the way r.json() does; numbers stay json.Number so
|
||||
// integer-valued fields render exactly like the Python oracle's values.
|
||||
func (r *Response) JSON() (any, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader(r.Body))
|
||||
dec.UseNumber()
|
||||
var v any
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Sender — injectable transport seam (httpx.get / httpx.post call sites).
|
||||
type Sender interface {
|
||||
Post(url string, headers map[string]string, jsonBody any, timeout time.Duration) (*Response, error)
|
||||
Get(url string, headers map[string]string, timeout time.Duration) (*Response, error)
|
||||
}
|
||||
|
||||
// ForgeTransportError mirrors httpx.TransportError's split: connect-phase
|
||||
// failures (ConnectError / ConnectTimeout — the request never reached the
|
||||
// server) vs non-connect transport failures (ReadTimeout & co — the server
|
||||
// MAY have seen it). The Kind strings are the frozen truth vocabulary.
|
||||
type ForgeTransportError struct {
|
||||
Kind string // httpx.ConnectError | httpx.ConnectTimeout | httpx.ReadTimeout | httpx.TransportError
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *ForgeTransportError) Error() string { return e.Kind + ": " + e.Msg }
|
||||
|
||||
// ConnectPhase reports whether a retry can never double-create.
|
||||
func (e *ForgeTransportError) ConnectPhase() bool {
|
||||
return e.Kind == "httpx.ConnectError" || e.Kind == "httpx.ConnectTimeout"
|
||||
}
|
||||
|
||||
// HTTPStatusError — the 4xx IS the information; never retried.
|
||||
type HTTPStatusError struct{ StatusCode int }
|
||||
|
||||
func (e *HTTPStatusError) Error() string {
|
||||
return fmt.Sprintf("httpx.HTTPStatusError: error %d", e.StatusCode)
|
||||
}
|
||||
|
||||
// HTTPXSender — the default real transport over net/http.
|
||||
type HTTPXSender struct{ Client *http.Client }
|
||||
|
||||
func NewHTTPXSender(client *http.Client) *HTTPXSender {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return &HTTPXSender{Client: client}
|
||||
}
|
||||
|
||||
var DefaultSender Sender = NewHTTPXSender(nil)
|
||||
|
||||
func (s *HTTPXSender) Post(url string, headers map[string]string, jsonBody any, timeout time.Duration) (*Response, error) {
|
||||
var body []byte
|
||||
if jsonBody != nil {
|
||||
b, err := json.Marshal(jsonBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body = b
|
||||
}
|
||||
return s.do("POST", url, headers, body, timeout)
|
||||
}
|
||||
|
||||
func (s *HTTPXSender) Get(url string, headers map[string]string, timeout time.Duration) (*Response, error) {
|
||||
return s.do("GET", url, headers, nil, timeout)
|
||||
}
|
||||
|
||||
func (s *HTTPXSender) do(method, url string, headers map[string]string, body []byte, timeout time.Duration) (*Response, error) {
|
||||
ctx := context.Background()
|
||||
var cancel context.CancelFunc
|
||||
if timeout > 0 {
|
||||
ctx, cancel = context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
}
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
rd = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, rd)
|
||||
if err != nil {
|
||||
return nil, &ForgeTransportError{Kind: "httpx.TransportError", Msg: err.Error()}
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := s.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, &ForgeTransportError{Kind: classifyTransportErr(err), Msg: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, rerr := io.ReadAll(resp.Body)
|
||||
if rerr != nil {
|
||||
return nil, &ForgeTransportError{Kind: "httpx.TransportError", Msg: rerr.Error()}
|
||||
}
|
||||
return &Response{StatusCode: resp.StatusCode, Body: b}, nil
|
||||
}
|
||||
|
||||
// classifyTransportErr maps net/http failures onto the httpx vocabulary.
|
||||
// Best-effort by nature (net/http does not label phases); dial and TLS
|
||||
// handshake failures are connect-phase, read-side timeouts are not.
|
||||
func classifyTransportErr(err error) string {
|
||||
var oe *net.OpError
|
||||
if errors.As(err, &oe) {
|
||||
switch oe.Op {
|
||||
case "dial":
|
||||
return "httpx.ConnectError"
|
||||
}
|
||||
if strings.Contains(strings.ToLower(oe.Error()), "handshake") {
|
||||
return "httpx.ConnectError"
|
||||
}
|
||||
}
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
if errors.As(err, &oe) && oe.Op == "dial" {
|
||||
return "httpx.ConnectTimeout"
|
||||
}
|
||||
return "httpx.ReadTimeout"
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "httpx.ReadTimeout"
|
||||
}
|
||||
return "httpx.TransportError"
|
||||
}
|
||||
|
||||
// ── _request ────────────────────────────────────────────────────────────────
|
||||
|
||||
// RequestOptions — the keyword surface of the Python _request.
|
||||
type RequestOptions struct {
|
||||
Headers map[string]string
|
||||
JSON any
|
||||
Timeout time.Duration
|
||||
Retries int
|
||||
Backoff time.Duration
|
||||
Sleep func(time.Duration)
|
||||
}
|
||||
|
||||
// DefaultRequestOptions — timeout=30, retries=2, backoff=0.5.
|
||||
func DefaultRequestOptions() RequestOptions {
|
||||
return RequestOptions{Timeout: DefaultTimeout, Retries: DefaultRetries, Backoff: DefaultBackoff}
|
||||
}
|
||||
|
||||
var DefaultSleep = time.Sleep
|
||||
|
||||
// Request — resilient single-request path: retry where failure is NOISE,
|
||||
// never where failure is the SIGNAL.
|
||||
//
|
||||
// - GET: retries on connect-phase errors AND any non-connect transport
|
||||
// error and 5xx status (transient infrastructure failure; idempotent).
|
||||
// - POST: retries ONLY on connect-phase errors — the request never
|
||||
// reached the server, so a retry cannot double-create. Read timeouts /
|
||||
// write errors / 5xx on a POST are NOT retried.
|
||||
// - 4xx: never retried — the client error IS the information.
|
||||
// - Attempts: retries+1 total. Backoff backoff*2**n before retry n+1;
|
||||
// never a sleep before the first attempt.
|
||||
func Request(sender Sender, method, url string, opts RequestOptions) (*Response, error) {
|
||||
sleeper := opts.Sleep
|
||||
if sleeper == nil {
|
||||
sleeper = DefaultSleep
|
||||
}
|
||||
isPost := strings.EqualFold(method, "POST")
|
||||
for attempt := 0; attempt <= opts.Retries; attempt++ {
|
||||
var resp *Response
|
||||
var err error
|
||||
if isPost {
|
||||
resp, err = sender.Post(url, opts.Headers, opts.JSON, opts.Timeout)
|
||||
} else {
|
||||
resp, err = sender.Get(url, opts.Headers, opts.Timeout)
|
||||
}
|
||||
if err != nil {
|
||||
var terr *ForgeTransportError
|
||||
if errors.As(err, &terr) && terr.ConnectPhase() {
|
||||
if attempt < opts.Retries {
|
||||
sleeper(opts.Backoff * (1 << attempt))
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// Non-connect transport error (read/write): only safe to retry
|
||||
// when the call is idempotent.
|
||||
if !isPost && attempt < opts.Retries {
|
||||
sleeper(opts.Backoff * (1 << attempt))
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 500 && !isPost && attempt < opts.Retries {
|
||||
sleeper(opts.Backoff * (1 << attempt))
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, &HTTPStatusError{StatusCode: resp.StatusCode}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
// Unreachable: every attempt either returns a successful response or
|
||||
// raises (mirror of the Python AssertionError guard).
|
||||
return nil, fmt.Errorf("forge_client: unreachable: Request always returns or raises")
|
||||
}
|
||||
|
||||
// ── ForgeClient protocol ────────────────────────────────────────────────────
|
||||
|
||||
// ForgeClient mirrors the Python runtime_checkable Protocol: create issues,
|
||||
// comment, check state, list, search.
|
||||
type ForgeClient interface {
|
||||
CreateIssue(title, body string) (any, error)
|
||||
AddComment(issueNumber int, body string) (any, error)
|
||||
GetIssueState(issueNumber int) (any, error)
|
||||
ListOpenIssues() ([]any, error)
|
||||
SearchIssues(query string) ([]any, error)
|
||||
}
|
||||
|
||||
// ── NullAdapter ─────────────────────────────────────────────────────────────
|
||||
|
||||
// NullAdapter — no-op adapter for unconfigured/standalone use. Zero errors.
|
||||
type NullAdapter struct{}
|
||||
|
||||
func (NullAdapter) CreateIssue(title, body string) (any, error) {
|
||||
return map[string]any{"created": false, "reason": "no forge configured"}, nil
|
||||
}
|
||||
|
||||
func (NullAdapter) AddComment(issueNumber int, body string) (any, error) {
|
||||
return map[string]any{"created": false, "reason": "no forge configured"}, nil
|
||||
}
|
||||
|
||||
func (NullAdapter) GetIssueState(issueNumber int) (any, error) {
|
||||
return map[string]any{"state": "unknown"}, nil
|
||||
}
|
||||
|
||||
func (NullAdapter) ListOpenIssues() ([]any, error) { return []any{}, nil }
|
||||
|
||||
func (NullAdapter) SearchIssues(query string) ([]any, error) { return []any{}, nil }
|
||||
|
||||
// ── GiteaForgejoAdapter ─────────────────────────────────────────────────────
|
||||
|
||||
// GiteaForgejoAdapter — /api/v1 paths (identical on both platforms).
|
||||
type GiteaForgejoAdapter struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
Sender Sender
|
||||
Sleep func(time.Duration)
|
||||
}
|
||||
|
||||
func NewGiteaForgejoAdapter(url, token, owner, repo string) *GiteaForgejoAdapter {
|
||||
return &GiteaForgejoAdapter{
|
||||
BaseURL: strings.TrimRight(url, "/") + "/api/v1/repos/" + owner + "/" + repo,
|
||||
Token: token,
|
||||
Sender: DefaultSender,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *GiteaForgejoAdapter) headers() map[string]string {
|
||||
return map[string]string{
|
||||
"Authorization": "token " + a.Token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
}
|
||||
|
||||
func (a *GiteaForgejoAdapter) opts() RequestOptions {
|
||||
o := DefaultRequestOptions()
|
||||
o.Headers = a.headers()
|
||||
o.Sleep = a.Sleep
|
||||
return o
|
||||
}
|
||||
|
||||
func (a *GiteaForgejoAdapter) CreateIssue(title, body string) (any, error) {
|
||||
o := a.opts()
|
||||
o.JSON = map[string]any{"title": title, "body": body}
|
||||
r, err := Request(a.Sender, "POST", a.BaseURL+"/issues", o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.JSON()
|
||||
}
|
||||
|
||||
func (a *GiteaForgejoAdapter) AddComment(issueNumber int, body string) (any, error) {
|
||||
o := a.opts()
|
||||
o.JSON = map[string]any{"body": body}
|
||||
r, err := Request(a.Sender, "POST",
|
||||
a.BaseURL+"/issues/"+strconv.Itoa(issueNumber)+"/comments", o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.JSON()
|
||||
}
|
||||
|
||||
func (a *GiteaForgejoAdapter) GetIssueState(issueNumber int) (any, error) {
|
||||
r, err := Request(a.Sender, "GET",
|
||||
a.BaseURL+"/issues/"+strconv.Itoa(issueNumber), a.opts())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.JSON()
|
||||
}
|
||||
|
||||
func (a *GiteaForgejoAdapter) ListOpenIssues() ([]any, error) {
|
||||
r, err := Request(a.Sender, "GET", a.BaseURL+"/issues?state=open&limit=50", a.opts())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, err := r.JSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
batch, _ := v.([]any)
|
||||
if batch == nil {
|
||||
batch = []any{}
|
||||
}
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
// SearchIssues — repo-scoped marker search over the paginated list endpoint.
|
||||
//
|
||||
// #122 gate fix: Gitea/Forgejo has NO repo-scoped /issues/search path
|
||||
// (verified against live swagger) — that URL 404s on a real forge. The
|
||||
// repo-scoped LIST endpoint supports state, type and q server-side and is
|
||||
// paginated: q matches titles (and keywords), so the full marker is sent
|
||||
// as q and a client-side contains-check keeps exactly the issues whose
|
||||
// title OR body contains the marker.
|
||||
func (a *GiteaForgejoAdapter) SearchIssues(query string) ([]any, error) {
|
||||
results := []any{}
|
||||
page := 1
|
||||
for {
|
||||
r, err := Request(a.Sender, "GET",
|
||||
a.BaseURL+"/issues?"+searchQuery(query, page), a.opts())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, err := r.JSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
batch, _ := v.([]any)
|
||||
results = append(results, batch...)
|
||||
if len(batch) < SearchPageSize {
|
||||
return filterMarker(results, query), nil
|
||||
}
|
||||
page++
|
||||
}
|
||||
}
|
||||
|
||||
// searchQuery — Python urlencode preserves insertion order; so does this.
|
||||
func searchQuery(q string, page int) string {
|
||||
return "state=open&type=issues&q=" + url.QueryEscape(q) +
|
||||
"&limit=50&page=" + strconv.Itoa(page)
|
||||
}
|
||||
|
||||
// filterMarker — `query in str(i.get("title","")) + str(i.get("body",""))`
|
||||
// with Python str() semantics (None → "None").
|
||||
func filterMarker(results []any, query string) []any {
|
||||
out := []any{}
|
||||
for _, item := range results {
|
||||
m, _ := item.(map[string]any)
|
||||
hay := pyDictStr(m, "title") + pyDictStr(m, "body")
|
||||
if strings.Contains(hay, query) {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pyDictStr(m map[string]any, key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
return pyStr(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pyStr(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return "None"
|
||||
case string:
|
||||
return t
|
||||
case bool:
|
||||
if t {
|
||||
return "True"
|
||||
}
|
||||
return "False"
|
||||
case json.Number:
|
||||
return t.String()
|
||||
default:
|
||||
return fmt.Sprint(t)
|
||||
}
|
||||
}
|
||||
|
||||
// ── create_forge_client ─────────────────────────────────────────────────────
|
||||
|
||||
// LookupEnv — injectable environment seam (os.environ reads).
|
||||
var LookupEnv = os.LookupEnv
|
||||
|
||||
func envOr(name, def string) string {
|
||||
if v, ok := LookupEnv(name); ok {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func cfgStr(cfg map[string]any, key string) string {
|
||||
if v, ok := cfg[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CreateForgeClient — create a forge client from config or environment.
|
||||
//
|
||||
// Config keys: forge.url, forge.token, forge.owner, forge.repo.
|
||||
// Falls back to FORGE_URL/FORGE_TOKEN/FORGE_OWNER/FORGE_REPO env vars.
|
||||
// Unconfigured → NullAdapter (graceful dormant).
|
||||
func CreateForgeClient(cfg map[string]any) ForgeClient {
|
||||
if cfg == nil {
|
||||
cfg = map[string]any{}
|
||||
}
|
||||
forgeURL := cfgStr(cfg, "forge.url")
|
||||
if forgeURL == "" {
|
||||
forgeURL = envOr("FORGE_URL", "")
|
||||
}
|
||||
token := cfgStr(cfg, "forge.token")
|
||||
if token == "" {
|
||||
token = envOr("FORGE_TOKEN", "")
|
||||
}
|
||||
if forgeURL == "" || token == "" {
|
||||
return NullAdapter{}
|
||||
}
|
||||
owner := cfgStr(cfg, "forge.owner")
|
||||
if owner == "" {
|
||||
owner = envOr("FORGE_OWNER", "owner")
|
||||
}
|
||||
repo := cfgStr(cfg, "forge.repo")
|
||||
if repo == "" {
|
||||
repo = envOr("FORGE_REPO", "repo")
|
||||
}
|
||||
return NewGiteaForgejoAdapter(forgeURL, token, owner, repo)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module rune/forge_client
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,26 @@
|
||||
// Parity gate — replays the frozen oracle truth (truth.json) through the
|
||||
// Go port and exits non-zero on any divergence. CI-invocable without a
|
||||
// test harness.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fails := RunTruth("truth.json")
|
||||
if len(fails) > 0 {
|
||||
for _, f := range fails {
|
||||
fmt.Fprintln(os.Stderr, "DIVERGED", f)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "forge_client: %d divergence(s) vs frozen oracle truth\n", len(fails))
|
||||
os.Exit(1)
|
||||
}
|
||||
doc, err := loadTruth("truth.json")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("forge_client: parity replay OK — %d frozen rows exact vs the Python oracle\n", len(doc.Rows))
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
// Replay — the frozen-truth parity engine. Reads truth.json (generated by
|
||||
// scripts/gen_forge_client_truth.py against the live Python oracle, httpx
|
||||
// transport scripted, sleep recorded) and re-executes every row through
|
||||
// this Go port, comparing canonically (sorted-key JSON).
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TruthDoc — the frozen truth file structure.
|
||||
type TruthDoc struct {
|
||||
FrozenTS string `json:"frozen_ts"`
|
||||
Oracle string `json:"oracle"`
|
||||
Rows []TruthRow `json:"rows"`
|
||||
}
|
||||
|
||||
// TruthRow — one frozen case.
|
||||
type TruthRow struct {
|
||||
Name string `json:"name"`
|
||||
Op string `json:"op"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
Want json.RawMessage `json:"want"`
|
||||
}
|
||||
|
||||
// RunTruth replays every row and returns a divergence report (empty on 100%).
|
||||
func RunTruth(path string) []string {
|
||||
doc, err := loadTruth(path)
|
||||
if err != nil {
|
||||
return []string{fmt.Sprintf("load truth: %v", err)}
|
||||
}
|
||||
var fails []string
|
||||
for i := range doc.Rows {
|
||||
if msg := compareOne(doc, &doc.Rows[i]); msg != "" {
|
||||
fails = append(fails, doc.Rows[i].Name+": "+msg)
|
||||
}
|
||||
}
|
||||
return fails
|
||||
}
|
||||
|
||||
func loadTruth(path string) (*TruthDoc, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc TruthDoc
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(doc.Rows) == 0 {
|
||||
return nil, fmt.Errorf("truth.json contained no rows")
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
// compareOne replays one row and returns "" on parity, else a divergence
|
||||
// description. Shared by RunTruth and the per-function battery tests.
|
||||
func compareOne(doc *TruthDoc, row *TruthRow) string {
|
||||
got, err := execRow(row)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error: %v", err)
|
||||
}
|
||||
wantStr, err := canonicalFromRaw(row.Want)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("canonicalize want: %v", err)
|
||||
}
|
||||
gotStr, err := canonicalFromRaw([]byte(mustJSON(got)))
|
||||
if err != nil {
|
||||
return fmt.Sprintf("canonicalize got: %v", err)
|
||||
}
|
||||
if wantStr != gotStr {
|
||||
return fmt.Sprintf("\n want: %s\n got: %s", wantStr, gotStr)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── scripted transport (mirrors the generator's fake httpx) ────────────────
|
||||
|
||||
type scriptEntry struct {
|
||||
Status int
|
||||
Payload any
|
||||
Exc string
|
||||
}
|
||||
|
||||
type scriptedSender struct {
|
||||
entries []scriptEntry
|
||||
requests *[]any
|
||||
}
|
||||
|
||||
func excKind(exc string) string {
|
||||
switch exc {
|
||||
case "connect":
|
||||
return "httpx.ConnectError"
|
||||
case "connect_timeout":
|
||||
return "httpx.ConnectTimeout"
|
||||
case "read_timeout":
|
||||
return "httpx.ReadTimeout"
|
||||
default:
|
||||
return "httpx.TransportError"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scriptedSender) record(method, url string, headers map[string]string, jsonBody any, timeout time.Duration) *scriptEntry {
|
||||
req := map[string]any{
|
||||
"method": method,
|
||||
"url": url,
|
||||
"headers": headers,
|
||||
"json": jsonBody,
|
||||
"timeout": timeout.Seconds(),
|
||||
}
|
||||
*s.requests = append(*s.requests, req)
|
||||
if len(s.entries) == 0 {
|
||||
panic(fmt.Sprintf("script exhausted at request %d (%s %s)", len(*s.requests), method, url))
|
||||
}
|
||||
entry := s.entries[0]
|
||||
s.entries = s.entries[1:]
|
||||
return &entry
|
||||
}
|
||||
|
||||
func (s *scriptedSender) respond(entry *scriptEntry) (*Response, error) {
|
||||
if entry.Exc != "" {
|
||||
return nil, &ForgeTransportError{Kind: excKind(entry.Exc), Msg: "scripted"}
|
||||
}
|
||||
status := entry.Status
|
||||
if status == 0 {
|
||||
status = 200
|
||||
}
|
||||
body, err := json.Marshal(entry.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entry.Payload == nil {
|
||||
body = []byte("{}")
|
||||
}
|
||||
return &Response{StatusCode: status, Body: body}, nil
|
||||
}
|
||||
|
||||
func (s *scriptedSender) Post(url string, headers map[string]string, jsonBody any, timeout time.Duration) (*Response, error) {
|
||||
return s.respond(s.record("POST", url, headers, jsonBody, timeout))
|
||||
}
|
||||
|
||||
func (s *scriptedSender) Get(url string, headers map[string]string, timeout time.Duration) (*Response, error) {
|
||||
return s.respond(s.record("GET", url, headers, nil, timeout))
|
||||
}
|
||||
|
||||
// ── row execution ───────────────────────────────────────────────────────────
|
||||
|
||||
func decodeInput(raw json.RawMessage) (map[string]any, error) {
|
||||
if len(raw) == 0 {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
var v any
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("input not an object")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func numInt(v any) int {
|
||||
switch t := v.(type) {
|
||||
case json.Number:
|
||||
n, err := t.Int64()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("not an integer: %v", t))
|
||||
}
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(t)
|
||||
default:
|
||||
panic(fmt.Sprintf("not a number: %v", v))
|
||||
}
|
||||
}
|
||||
|
||||
func numFloat(v any) float64 {
|
||||
switch t := v.(type) {
|
||||
case json.Number:
|
||||
f, _ := t.Float64()
|
||||
return f
|
||||
case float64:
|
||||
return t
|
||||
default:
|
||||
panic(fmt.Sprintf("not a number: %v", v))
|
||||
}
|
||||
}
|
||||
|
||||
// wantRecorder builds the {result, requests, sleeps, error} got-shape the
|
||||
// frozen rows carry.
|
||||
type recorder struct {
|
||||
requests []any
|
||||
sleeps []any
|
||||
errKind string
|
||||
}
|
||||
|
||||
func (r *recorder) sleep(d time.Duration) { r.sleeps = append(r.sleeps, d.Seconds()) }
|
||||
|
||||
func (r *recorder) got(result any) map[string]any {
|
||||
return map[string]any{
|
||||
"result": result,
|
||||
"requests": r.requests,
|
||||
"sleeps": r.sleeps,
|
||||
"error": errKindOrNil(r.errKind),
|
||||
}
|
||||
}
|
||||
|
||||
func errKindOrNil(kind string) any {
|
||||
if kind == "" {
|
||||
return nil
|
||||
}
|
||||
return kind
|
||||
}
|
||||
|
||||
func errorKind(err error) string {
|
||||
var terr *ForgeTransportError
|
||||
if errors.As(err, &terr) {
|
||||
return terr.Kind
|
||||
}
|
||||
var serr *HTTPStatusError
|
||||
if errors.As(err, &serr) {
|
||||
return "httpx.HTTPStatusError"
|
||||
}
|
||||
return fmt.Sprintf("%T", err)
|
||||
}
|
||||
|
||||
func execRow(row *TruthRow) (any, error) {
|
||||
in, err := decodeInput(row.Input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode input: %w", err)
|
||||
}
|
||||
|
||||
switch row.Op {
|
||||
case "select":
|
||||
return execSelect(in)
|
||||
|
||||
case "null":
|
||||
return execNull(in)
|
||||
|
||||
case "call":
|
||||
return execCall(in)
|
||||
|
||||
case "request":
|
||||
return execRequest(in)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown row op %s (%s)", row.Op, row.Name)
|
||||
}
|
||||
|
||||
func execSelect(in map[string]any) (any, error) {
|
||||
var cfg map[string]any
|
||||
if v, ok := in["config"]; ok && v != nil {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("select: config not an object")
|
||||
}
|
||||
cfg = m
|
||||
}
|
||||
env := map[string]string{}
|
||||
if v, ok := in["env"].(map[string]any); ok {
|
||||
for k, val := range v {
|
||||
s, _ := val.(string)
|
||||
env[k] = s
|
||||
}
|
||||
}
|
||||
saved := LookupEnv
|
||||
defer func() { LookupEnv = saved }()
|
||||
LookupEnv = func(name string) (string, bool) {
|
||||
v, ok := env[name]
|
||||
return v, ok
|
||||
}
|
||||
client := CreateForgeClient(cfg)
|
||||
if _, isNull := client.(NullAdapter); isNull {
|
||||
return map[string]any{"adapter": "NullAdapter", "base_url": nil, "token": nil}, nil
|
||||
}
|
||||
a := client.(*GiteaForgejoAdapter)
|
||||
return map[string]any{"adapter": "GiteaForgejoAdapter", "base_url": a.BaseURL, "token": a.Token}, nil
|
||||
}
|
||||
|
||||
func execNull(in map[string]any) (any, error) {
|
||||
method, _ := in["method"].(string)
|
||||
args, _ := in["args"].([]any)
|
||||
client := NullAdapter{}
|
||||
switch method {
|
||||
case "create_issue":
|
||||
return client.CreateIssue(argStr(args, 0), argStr(args, 1))
|
||||
case "add_comment":
|
||||
return client.AddComment(numInt(args[0]), argStr(args, 1))
|
||||
case "get_issue_state":
|
||||
return client.GetIssueState(numInt(args[0]))
|
||||
case "list_open_issues":
|
||||
return client.ListOpenIssues()
|
||||
case "search_issues":
|
||||
return client.SearchIssues(argStr(args, 0))
|
||||
}
|
||||
return nil, fmt.Errorf("null: unknown method %s", method)
|
||||
}
|
||||
|
||||
func argStr(args []any, i int) string {
|
||||
if i < len(args) {
|
||||
s, _ := args[i].(string)
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func newAdapterFrom(in map[string]any, rec *recorder) (*GiteaForgejoAdapter, *scriptedSender, error) {
|
||||
method, _ := in["method"].(string)
|
||||
url, _ := in["url"].(string)
|
||||
token, _ := in["token"].(string)
|
||||
owner, _ := in["owner"].(string)
|
||||
repo, _ := in["repo"].(string)
|
||||
entries, err := decodeScript(in["script"])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sender := &scriptedSender{entries: entries, requests: &rec.requests}
|
||||
a := NewGiteaForgejoAdapter(url, token, owner, repo)
|
||||
a.Sender = sender
|
||||
a.Sleep = rec.sleep
|
||||
_ = method
|
||||
return a, sender, nil
|
||||
}
|
||||
|
||||
func decodeScript(v any) ([]scriptEntry, error) {
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("script not a list")
|
||||
}
|
||||
out := make([]scriptEntry, 0, len(list))
|
||||
for _, item := range list {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("script entry not an object")
|
||||
}
|
||||
e := scriptEntry{}
|
||||
if exc, ok := m["exc"].(string); ok {
|
||||
e.Exc = exc
|
||||
}
|
||||
if status, ok := m["status"]; ok {
|
||||
e.Status = numInt(status)
|
||||
}
|
||||
if payload, ok := m["payload"]; ok {
|
||||
e.Payload = payload
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func execCall(in map[string]any) (any, error) {
|
||||
method, _ := in["method"].(string)
|
||||
rec := &recorder{}
|
||||
a, _, err := newAdapterFrom(in, rec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kwargs, _ := in["kwargs"].(map[string]any)
|
||||
var result any
|
||||
switch method {
|
||||
case "create_issue":
|
||||
result, err = a.CreateIssue(kwStr(kwargs, "title"), kwStr(kwargs, "body"))
|
||||
case "add_comment":
|
||||
result, err = a.AddComment(numInt(kwargs["issue_number"]), kwStr(kwargs, "body"))
|
||||
case "get_issue_state":
|
||||
result, err = a.GetIssueState(numInt(kwargs["issue_number"]))
|
||||
case "list_open_issues":
|
||||
result, err = a.ListOpenIssues()
|
||||
case "search_issues":
|
||||
result, err = a.SearchIssues(kwStr(kwargs, "query"))
|
||||
default:
|
||||
return nil, fmt.Errorf("call: unknown method %s", method)
|
||||
}
|
||||
if err != nil {
|
||||
rec.errKind = errorKind(err)
|
||||
result = nil
|
||||
}
|
||||
return rec.got(result), nil
|
||||
}
|
||||
|
||||
func kwStr(m map[string]any, key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func execRequest(in map[string]any) (any, error) {
|
||||
rec := &recorder{}
|
||||
method, _ := in["method"].(string)
|
||||
rawURL, _ := in["url"].(string)
|
||||
entries, err := decodeScript(in["script"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sender := &scriptedSender{entries: entries, requests: &rec.requests}
|
||||
opts := RequestOptions{
|
||||
Headers: toStringMap(in["headers"]),
|
||||
JSON: in["json"],
|
||||
Timeout: secondsDuration(in["timeout"]),
|
||||
Retries: numInt(in["retries"]),
|
||||
Backoff: secondsDuration(in["backoff"]),
|
||||
Sleep: rec.sleep,
|
||||
}
|
||||
resp, err := Request(sender, method, rawURL, opts)
|
||||
var result any
|
||||
if err != nil {
|
||||
rec.errKind = errorKind(err)
|
||||
} else {
|
||||
result, err = resp.JSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return rec.got(result), nil
|
||||
}
|
||||
|
||||
func toStringMap(v any) map[string]string {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, val := range m {
|
||||
s, _ := val.(string)
|
||||
out[k] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func secondsDuration(v any) time.Duration {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(numFloat(v) * float64(time.Second))
|
||||
}
|
||||
|
||||
// ── canonical comparison (sorted-key JSON) ──────────────────────────────────
|
||||
|
||||
func canonicalFromRaw(raw []byte) (string, error) {
|
||||
var v any
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return canonical(normalize(v)), nil
|
||||
}
|
||||
|
||||
// canonical — sorted-key deterministic rendering of a decoded JSON value.
|
||||
func canonical(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case bool:
|
||||
if t {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case string:
|
||||
b, _ := json.Marshal(t)
|
||||
return string(b)
|
||||
case float64:
|
||||
if t == float64(int64(t)) {
|
||||
return fmt.Sprintf("%d", int64(t))
|
||||
}
|
||||
return fmt.Sprintf("%v", t)
|
||||
case json.Number:
|
||||
f, _ := t.Float64()
|
||||
return canonical(f)
|
||||
case []any:
|
||||
parts := make([]string, len(t))
|
||||
for i, item := range t {
|
||||
parts[i] = canonical(normalize(item))
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(t))
|
||||
for k := range t {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
bk, _ := json.Marshal(k)
|
||||
parts = append(parts, string(bk)+": "+canonical(normalize(t[k])))
|
||||
}
|
||||
return "{" + strings.Join(parts, ", ") + "}"
|
||||
default:
|
||||
return fmt.Sprintf("«%v»", t)
|
||||
}
|
||||
}
|
||||
|
||||
// normalize — flatten trees into plain map/float64 representations so
|
||||
// canonical() sees one shape.
|
||||
func normalize(v any) any {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
m := map[string]any{}
|
||||
for k, val := range t {
|
||||
m[k] = normalize(val)
|
||||
}
|
||||
return m
|
||||
case []any:
|
||||
out := make([]any, len(t))
|
||||
for i, item := range t {
|
||||
out[i] = normalize(item)
|
||||
}
|
||||
return out
|
||||
case json.Number:
|
||||
f, _ := t.Float64()
|
||||
return f
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
b, err := json.Marshal(normalize(v))
|
||||
if err != nil {
|
||||
return "null"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,285 @@
|
||||
package main
|
||||
|
||||
// Battery — the HARD parity gate plus per-function batteries replaying the
|
||||
// frozen oracle truth (truth.json, generated by
|
||||
// scripts/gen_forge_client_truth.py against the LIVE Python oracle with a
|
||||
// scripted httpx transport and a recording sleep) VERBATIM. Any divergence
|
||||
// fails; the truth file rides the repo so the gate holds in CI without a
|
||||
// live forge.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func loadTruthOrDie(t *testing.T) *TruthDoc {
|
||||
t.Helper()
|
||||
doc, err := loadTruth("truth.json")
|
||||
if err != nil {
|
||||
t.Fatalf("truth.json unusable (frozen oracle battery): %v", err)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
func findRow(t *testing.T, name string) TruthRow {
|
||||
t.Helper()
|
||||
doc := loadTruthOrDie(t)
|
||||
for _, row := range doc.Rows {
|
||||
if row.Name == name {
|
||||
return row
|
||||
}
|
||||
}
|
||||
t.Fatalf("frozen row %q missing from truth.json — battery/truth drift", name)
|
||||
return TruthRow{}
|
||||
}
|
||||
|
||||
func mustReplay(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
doc := loadTruthOrDie(t)
|
||||
row := findRow(t, name)
|
||||
if msg := compareOne(doc, &row); msg != "" {
|
||||
t.Fatalf("DIVERGED %s: %s", name, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func replayOp(t *testing.T, op string) int {
|
||||
t.Helper()
|
||||
doc := loadTruthOrDie(t)
|
||||
n := 0
|
||||
for i := range doc.Rows {
|
||||
row := &doc.Rows[i]
|
||||
if row.Op != op {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
if msg := compareOne(doc, row); msg != "" {
|
||||
t.Fatalf("DIVERGED %s: %s", row.Name, msg)
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatalf("no %s rows in truth.json", op)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// rowWant decodes a row's want JSON into a generic map.
|
||||
func rowWant(t *testing.T, name string) map[string]any {
|
||||
t.Helper()
|
||||
row := findRow(t, name)
|
||||
var want map[string]any
|
||||
if err := json.Unmarshal(row.Want, &want); err != nil {
|
||||
t.Fatalf("decode want of %s: %v", name, err)
|
||||
}
|
||||
return want
|
||||
}
|
||||
|
||||
// TestTruthParity — the whole frozen battery, one shot.
|
||||
func TestTruthParity(t *testing.T) {
|
||||
doc := loadTruthOrDie(t)
|
||||
var fails []string
|
||||
for i := range doc.Rows {
|
||||
if msg := compareOne(doc, &doc.Rows[i]); msg != "" {
|
||||
fails = append(fails, doc.Rows[i].Name+": "+msg)
|
||||
}
|
||||
}
|
||||
if len(fails) > 0 {
|
||||
t.Fatalf("parity replay diverged after %d clean rows:\n%s",
|
||||
len(doc.Rows)-len(fails), strings.Join(fails, "\n"))
|
||||
}
|
||||
t.Logf("PARITY OK: %d cases exact-equal vs the Python oracle", len(doc.Rows))
|
||||
}
|
||||
|
||||
// TestBatteryCreateForgeClient — every selection branch: none, env, config
|
||||
// keys, config-beats-env, owner/repo defaults and env overrides, trailing
|
||||
// slash strip, empty-string falsy fallthrough, missing halves.
|
||||
func TestBatteryCreateForgeClient(t *testing.T) {
|
||||
if n := replayOp(t, "select"); n != 11 {
|
||||
t.Fatalf("expected 11 frozen select rows, got %d — battery/truth drift", n)
|
||||
}
|
||||
want := rowWant(t, "select:none")
|
||||
if want["adapter"] != "NullAdapter" {
|
||||
t.Fatalf("unconfigured must be NullAdapter, got %v", want["adapter"])
|
||||
}
|
||||
want = rowWant(t, "select:env-configured")
|
||||
if want["base_url"] != "https://forge.example.com/api/v1/repos/owner/repo" {
|
||||
t.Fatalf("frozen env base_url changed: %v", want["base_url"])
|
||||
}
|
||||
want = rowWant(t, "select:config-beats-env")
|
||||
if want["base_url"] != "https://cfg.example.com/api/v1/repos/owner/repo" ||
|
||||
want["token"] != "cfg_tok" {
|
||||
t.Fatalf("config-beats-env changed: %v", want)
|
||||
}
|
||||
want = rowWant(t, "select:trailing-slash-strip")
|
||||
if want["base_url"] != "https://forge.example.com/api/v1/repos/owner/repo" {
|
||||
t.Fatalf("trailing slash strip changed: %v", want["base_url"])
|
||||
}
|
||||
want = rowWant(t, "select:cfg-url-missing-token")
|
||||
if want["adapter"] != "NullAdapter" {
|
||||
t.Fatalf("url without token must stay dormant: %v", want["adapter"])
|
||||
}
|
||||
mustReplay(t, "select:owner-empty-env")
|
||||
}
|
||||
|
||||
// TestBatteryNullAdapter — every no-op shape, zero errors.
|
||||
func TestBatteryNullAdapter(t *testing.T) {
|
||||
if n := replayOp(t, "null"); n != 5 {
|
||||
t.Fatalf("expected 5 frozen null rows, got %d — battery/truth drift", n)
|
||||
}
|
||||
want := rowWant(t, "null:create-issue")
|
||||
if want["created"] != false || want["reason"] != "no forge configured" {
|
||||
t.Fatalf("frozen null create changed: %v", want)
|
||||
}
|
||||
want = rowWant(t, "null:get-issue-state")
|
||||
if want["state"] != "unknown" {
|
||||
t.Fatalf("frozen null state changed: %v", want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatteryGiteaAdapter — every adapter method incl. pagination, the
|
||||
// client-side marker filter, and retry propagation through _request.
|
||||
func TestBatteryGiteaAdapter(t *testing.T) {
|
||||
if n := replayOp(t, "call"); n != 14 {
|
||||
t.Fatalf("expected 14 frozen call rows, got %d — battery/truth drift", n)
|
||||
}
|
||||
// create_issue posts to /issues with token auth and the JSON body.
|
||||
want := rowWant(t, "call:create-issue")
|
||||
reqs, _ := want["requests"].([]any)
|
||||
if len(reqs) != 1 {
|
||||
t.Fatalf("create-issue: expected 1 request, got %d", len(reqs))
|
||||
}
|
||||
req, _ := reqs[0].(map[string]any)
|
||||
if !strings.HasSuffix(fmt.Sprint(req["url"]), "/api/v1/repos/o/r/issues") {
|
||||
t.Fatalf("create-issue URL changed: %v", req["url"])
|
||||
}
|
||||
headers, _ := req["headers"].(map[string]any)
|
||||
if headers["Authorization"] != "token tok" || headers["Content-Type"] != "application/json" {
|
||||
t.Fatalf("frozen auth headers changed: %v", headers)
|
||||
}
|
||||
if fmt.Sprint(req["timeout"]) != "30" {
|
||||
t.Fatalf("frozen timeout passthrough changed: %v", req["timeout"])
|
||||
}
|
||||
// search hits the repo-scoped LIST endpoint, never /issues/search.
|
||||
want = rowWant(t, "call:search-single-page")
|
||||
reqs, _ = want["requests"].([]any)
|
||||
req, _ = reqs[0].(map[string]any)
|
||||
searchURL := fmt.Sprint(req["url"])
|
||||
if strings.Contains(searchURL, "/issues/search") {
|
||||
t.Fatalf("regression: search used the 404ing /issues/search path")
|
||||
}
|
||||
if !strings.Contains(searchURL, "state=open") ||
|
||||
!strings.Contains(searchURL, "type=issues") ||
|
||||
!strings.Contains(searchURL, "q=%5Bauto-failure%3Acron%3AConnection%5D") {
|
||||
t.Fatalf("frozen search query changed: %s", searchURL)
|
||||
}
|
||||
// pagination: a full 50-item page must be followed by page=2.
|
||||
want = rowWant(t, "call:search-paginates")
|
||||
reqs, _ = want["requests"].([]any)
|
||||
if len(reqs) != 2 || !strings.Contains(fmt.Sprint(reqs[1].(map[string]any)["url"]), "page=2") {
|
||||
t.Fatalf("frozen pagination changed: %d requests", len(reqs))
|
||||
}
|
||||
// retry through _request: connect-phase POST retried once, sleep 0.5s.
|
||||
want = rowWant(t, "call:create-issue-retry-connect")
|
||||
sleeps, _ := want["sleeps"].([]any)
|
||||
if len(sleeps) != 1 || fmt.Sprint(sleeps[0]) != "0.5" {
|
||||
t.Fatalf("frozen connect retry sleeps changed: %v", sleeps)
|
||||
}
|
||||
// 4xx is the information: one shot, no retry.
|
||||
want = rowWant(t, "call:get-issue-state-4xx")
|
||||
if want["error"] != "httpx.HTTPStatusError" {
|
||||
t.Fatalf("frozen 4xx error kind changed: %v", want["error"])
|
||||
}
|
||||
reqs, _ = want["requests"].([]any)
|
||||
if len(reqs) != 1 {
|
||||
t.Fatalf("4xx must not retry: %d requests", len(reqs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatteryRequest — every _request branch: noise retried, signal never,
|
||||
// exponential backoff never before the first attempt, timeout passthrough,
|
||||
// lowercase method routing.
|
||||
func TestBatteryRequest(t *testing.T) {
|
||||
if n := replayOp(t, "request"); n != 19 {
|
||||
t.Fatalf("expected 19 frozen request rows, got %d — battery/truth drift", n)
|
||||
}
|
||||
// backoff is exponential: [0.5, 1.0], never before the first attempt.
|
||||
want := rowWant(t, "request:backoff-exponential")
|
||||
sleeps, _ := want["sleeps"].([]any)
|
||||
if len(sleeps) != 2 || fmt.Sprint(sleeps[0]) != "0.5" || fmt.Sprint(sleeps[1]) != "1" {
|
||||
t.Fatalf("frozen backoff schedule changed: %v", sleeps)
|
||||
}
|
||||
// connect exhaustion: retries+1 attempts, error propagates.
|
||||
want = rowWant(t, "request:get-connect-exhaust")
|
||||
if want["error"] != "httpx.ConnectError" {
|
||||
t.Fatalf("frozen exhaust error changed: %v", want["error"])
|
||||
}
|
||||
reqs, _ := want["requests"].([]any)
|
||||
if len(reqs) != 3 {
|
||||
t.Fatalf("frozen attempt count changed: %d", len(reqs))
|
||||
}
|
||||
// POST read timeout: the server MAY have processed it — one shot.
|
||||
want = rowWant(t, "request:post-read-timeout-no-retry")
|
||||
if want["error"] != "httpx.ReadTimeout" {
|
||||
t.Fatalf("frozen read-timeout kind changed: %v", want["error"])
|
||||
}
|
||||
reqs, _ = want["requests"].([]any)
|
||||
if len(reqs) != 1 {
|
||||
t.Fatalf("POST read timeout must not retry: %d requests", len(reqs))
|
||||
}
|
||||
// POST 5xx after send: ambiguous — one shot.
|
||||
want = rowWant(t, "request:post-5xx-no-retry")
|
||||
if want["error"] != "httpx.HTTPStatusError" {
|
||||
t.Fatalf("frozen post-5xx kind changed: %v", want["error"])
|
||||
}
|
||||
// timeout passes through per call.
|
||||
want = rowWant(t, "request:timeout-passthrough")
|
||||
reqs, _ = want["requests"].([]any)
|
||||
if fmt.Sprint(reqs[0].(map[string]any)["timeout"]) != "17" {
|
||||
t.Fatalf("frozen timeout passthrough changed: %v", reqs[0])
|
||||
}
|
||||
mustReplay(t, "request:lowercase-method")
|
||||
mustReplay(t, "request:headers-json-capture")
|
||||
mustReplay(t, "request:get-5xx-exhaust")
|
||||
mustReplay(t, "request:get-read-timeout-retries")
|
||||
}
|
||||
|
||||
// TestBatteryRetryAsymmetry — direct cross-check (not via a truth row) of
|
||||
// the behavior that motivated the module: GET 5xx is noise (retried to
|
||||
// success), POST 5xx is signal (one shot, no double-create).
|
||||
func TestBatteryRetryAsymmetry(t *testing.T) {
|
||||
getRequests := &[]any{}
|
||||
getSender := &scriptedSender{
|
||||
entries: []scriptEntry{{Status: 502}, {Status: 200, Payload: map[string]any{"ok": 1}}},
|
||||
requests: getRequests,
|
||||
}
|
||||
opts := DefaultRequestOptions()
|
||||
opts.Sleep = func(time.Duration) {}
|
||||
if _, err := Request(getSender, "GET", "https://x/api", opts); err != nil {
|
||||
t.Fatalf("GET 5xx must be retried to success: %v", err)
|
||||
}
|
||||
if len(*getRequests) != 2 {
|
||||
t.Fatalf("GET 5xx made %d requests — retry lost", len(*getRequests))
|
||||
}
|
||||
|
||||
postRequests := &[]any{}
|
||||
postSender := &scriptedSender{
|
||||
entries: []scriptEntry{{Status: 502}, {Status: 200}},
|
||||
requests: postRequests,
|
||||
}
|
||||
postOpts := DefaultRequestOptions()
|
||||
postOpts.JSON = map[string]any{}
|
||||
postOpts.Sleep = func(time.Duration) {}
|
||||
if _, err := Request(postSender, "POST", "https://x/api", postOpts); err == nil {
|
||||
t.Fatal("POST 5xx must never be retried")
|
||||
}
|
||||
if len(*postRequests) != 1 {
|
||||
t.Fatalf("POST 5xx made %d requests — double-create hazard", len(*postRequests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
Reference in new issue
Block a user