Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

## Table of Contents

- [What's new (2026-06-21) — HTTP Record & Replay Cassette](#whats-new-2026-06-21--http-record--replay-cassette)
- [What's new (2026-06-21) — Bulkhead & Rate-Limit Headers](#whats-new-2026-06-21--bulkhead--rate-limit-headers)
- [What's new (2026-06-21) — Streaming Latency Percentiles](#whats-new-2026-06-21--streaming-latency-percentiles)
- [What's new (2026-06-21) — Service-Level Objectives (SLO)](#whats-new-2026-06-21--service-level-objectives-slo)
Expand Down Expand Up @@ -127,6 +128,12 @@

---

## What's new (2026-06-21) — HTTP Record & Replay Cassette

Re-run API flows in CI with no live server. Full reference: [`docs/source/Eng/doc/new_features/v75_features_doc.rst`](docs/source/Eng/doc/new_features/v75_features_doc.rst).

- **`Cassette` / `CassetteMissError`** (`AC_http_replay`): the HTTP client hardcoded its `urllib` transport, so a flow driving a real API couldn't be re-run offline. The client now exposes a `build_call` / `urllib_transport` seam, and this adds a VCR-style cassette — `replay` returns a recorded response for a matching request (pure, no network — the CI-valuable half), `recording_transport` is a thin pass-through over the live transport. Match on `method`/`url` (optionally `body`); `save`/`load` JSON cassettes. Pure-stdlib.

## What's new (2026-06-21) — Bulkhead & Rate-Limit Headers

Cap concurrency, honor server back-off. Full reference: [`docs/source/Eng/doc/new_features/v74_features_doc.rst`](docs/source/Eng/doc/new_features/v74_features_doc.rst).
Expand Down
7 changes: 7 additions & 0 deletions README/README_zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

## 目录

- [本次更新 (2026-06-21) — HTTP 录制与重播卡带](#本次更新-2026-06-21--http-录制与重播卡带)
- [本次更新 (2026-06-21) — 隔舱与速率限制标头](#本次更新-2026-06-21--隔舱与速率限制标头)
- [本次更新 (2026-06-21) — 流式延迟百分位](#本次更新-2026-06-21--流式延迟百分位)
- [本次更新 (2026-06-21) — 服务等级目标(SLO)](#本次更新-2026-06-21--服务等级目标slo)
Expand Down Expand Up @@ -126,6 +127,12 @@

---

## 本次更新 (2026-06-21) — HTTP 录制与重播卡带

在 CI 中重跑 API 流程,无需在线服务器。完整参考:[`docs/source/Zh/doc/new_features/v75_features_doc.rst`](../docs/source/Zh/doc/new_features/v75_features_doc.rst)。

- **`Cassette` / `CassetteMissError`**(`AC_http_replay`):HTTP 客户端把 `urllib` 传输写死,因此驱动真实 API 的流程无法离线重跑。客户端现在开放 `build_call` / `urllib_transport` 接缝,本功能加入 VCR 风格卡带 —— `replay` 为相符请求返回已录制响应(纯粹、不联网,对 CI 最有价值的一半),`recording_transport` 则是在实际传输之上的薄薄转送。可按 `method`/`url`(可加 `body`)匹配;以 JSON `save`/`load` 卡带。纯标准库。

## 本次更新 (2026-06-21) — 隔舱与速率限制标头

限制并发、遵守服务器退避。完整参考:[`docs/source/Zh/doc/new_features/v74_features_doc.rst`](../docs/source/Zh/doc/new_features/v74_features_doc.rst)。
Expand Down
7 changes: 7 additions & 0 deletions README/README_zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

## 目錄

- [本次更新 (2026-06-21) — HTTP 錄製與重播卡帶](#本次更新-2026-06-21--http-錄製與重播卡帶)
- [本次更新 (2026-06-21) — 隔艙與速率限制標頭](#本次更新-2026-06-21--隔艙與速率限制標頭)
- [本次更新 (2026-06-21) — 串流延遲百分位](#本次更新-2026-06-21--串流延遲百分位)
- [本次更新 (2026-06-21) — 服務等級目標(SLO)](#本次更新-2026-06-21--服務等級目標slo)
Expand Down Expand Up @@ -126,6 +127,12 @@

---

## 本次更新 (2026-06-21) — HTTP 錄製與重播卡帶

在 CI 中重跑 API 流程,無需線上伺服器。完整參考:[`docs/source/Zh/doc/new_features/v75_features_doc.rst`](../docs/source/Zh/doc/new_features/v75_features_doc.rst)。

- **`Cassette` / `CassetteMissError`**(`AC_http_replay`):HTTP 用戶端把 `urllib` 傳輸寫死,因此驅動真實 API 的流程無法離線重跑。用戶端現在開放 `build_call` / `urllib_transport` 接縫,本功能加入 VCR 風格卡帶 —— `replay` 為相符請求回傳已錄製回應(純粹、不連網,對 CI 最有價值的一半),`recording_transport` 則是在實際傳輸之上的薄薄轉送。可依 `method`/`url`(可加 `body`)比對;以 JSON `save`/`load` 卡帶。純標準函式庫。

## 本次更新 (2026-06-21) — 隔艙與速率限制標頭

限制並行、遵守伺服器退避。完整參考:[`docs/source/Zh/doc/new_features/v74_features_doc.rst`](../docs/source/Zh/doc/new_features/v74_features_doc.rst)。
Expand Down
46 changes: 46 additions & 0 deletions docs/source/Eng/doc/new_features/v75_features_doc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
HTTP Record & Replay Cassette
=============================

The HTTP client hardcoded its ``urllib`` transport, so a flow that drives a
real API could not be re-run in CI without the live server reachable. The
client now exposes a ``build_call`` / ``urllib_transport`` seam, and this layer
adds a VCR-style **cassette**: replay returns a recorded response for a
matching request (pure, no network — the CI-valuable half), while recording is
a thin pass-through over a live transport.

Pure standard library (``json``); imports no ``PySide6``.

Headless API
------------

.. code-block:: python

from je_auto_control import Cassette, CassetteMissError
from je_auto_control.utils.http_client import build_call, urllib_transport

# Record once (against the live server), then save:
cassette = Cassette()
transport = cassette.recording_transport(urllib_transport)
transport(build_call("https://api.example.com/users/1", "GET"))
cassette.save("users.cassette.json")

# Replay forever — deterministic, offline:
cassette = Cassette.load("users.cassette.json")
response = cassette.replay(build_call("https://api.example.com/users/1", "GET"))
assert response["status"] == 200

``build_call`` turns request parameters into a plain dict (url, method, headers,
body, timeout) without touching the network; ``urllib_transport`` performs it.
``Cassette.record`` stores one request/response pair; ``replay`` returns the
recorded response for a request matching ``match_on`` (``("method", "url")`` by
default, optionally ``"body"``) and raises ``CassetteMissError`` when nothing
matches. ``replay_transport`` / ``recording_transport`` return drop-in
transports so existing call sites swap live traffic for the cassette unchanged.

Executor command
----------------

``AC_http_replay`` takes a ``cassette`` (interactions list or ``{interactions}``,
JSON string accepted), a ``url`` and optional ``method``, and returns the
recorded ``{response}`` with no network access. It is exposed as the MCP tool
``ac_http_replay`` and as a Script Builder command under **Data**.
1 change: 1 addition & 0 deletions docs/source/Eng/eng_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ Comprehensive guides for all AutoControl features.
doc/new_features/v72_features_doc
doc/new_features/v73_features_doc
doc/new_features/v74_features_doc
doc/new_features/v75_features_doc
doc/ocr_backends/ocr_backends_doc
doc/observability/observability_doc
doc/operations_layer/operations_layer_doc
Expand Down
41 changes: 41 additions & 0 deletions docs/source/Zh/doc/new_features/v75_features_doc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
HTTP 錄製與重播卡帶
==================

HTTP 用戶端把 ``urllib`` 傳輸寫死,因此一個驅動真實 API 的流程,若沒有可連線的線上伺服器便無法在
CI 重跑。用戶端現在開放 ``build_call`` / ``urllib_transport`` 接縫,本層在其上加入 VCR 風格的
**卡帶**:重播會為相符的請求回傳已錄製的回應(純粹、不連網 —— 對 CI 最有價值的一半),而錄製則是
在實際傳輸之上的薄薄一層轉送。

純標準函式庫(``json``);不匯入 ``PySide6``。

無頭 API
--------

.. code-block:: python

from je_auto_control import Cassette, CassetteMissError
from je_auto_control.utils.http_client import build_call, urllib_transport

# 對線上伺服器錄製一次,然後存檔:
cassette = Cassette()
transport = cassette.recording_transport(urllib_transport)
transport(build_call("https://api.example.com/users/1", "GET"))
cassette.save("users.cassette.json")

# 之後永遠重播 —— 具決定性、離線:
cassette = Cassette.load("users.cassette.json")
response = cassette.replay(build_call("https://api.example.com/users/1", "GET"))
assert response["status"] == 200

``build_call`` 把請求參數轉成純 dict(url、method、headers、body、timeout)而不碰網路;
``urllib_transport`` 負責實際發送。``Cassette.record`` 儲存一組請求/回應;``replay`` 為符合
``match_on``(預設 ``("method", "url")``,可加 ``"body"``)的請求回傳已錄製的回應,找不到時拋出
``CassetteMissError``。``replay_transport`` / ``recording_transport`` 回傳可直接替換的傳輸,讓既有
呼叫端在不變動下把實際流量換成卡帶。

執行器命令
----------

``AC_http_replay`` 接受 ``cassette``(interactions 清單或 ``{interactions}``,可為 JSON 字串)、
``url`` 與選用的 ``method``,在不連網的情況下回傳已錄製的 ``{response}``。它以 MCP 工具
``ac_http_replay`` 以及 Script Builder 中 **Data** 分類下的命令提供。
1 change: 1 addition & 0 deletions docs/source/Zh/zh_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ AutoControl 所有功能的完整使用指南。
doc/new_features/v72_features_doc
doc/new_features/v73_features_doc
doc/new_features/v74_features_doc
doc/new_features/v75_features_doc
doc/ocr_backends/ocr_backends_doc
doc/observability/observability_doc
doc/operations_layer/operations_layer_doc
Expand Down
3 changes: 3 additions & 0 deletions je_auto_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,8 @@
from je_auto_control.utils.bulkhead import (
Bulkhead, BulkheadFullError, next_delay, parse_ratelimit, parse_retry_after,
)
# HTTP record/replay cassette (deterministic offline API tests)
from je_auto_control.utils.http_cassette import Cassette, CassetteMissError
# Background popup/interrupt watchdog (unattended automation)
from je_auto_control.utils.watchdog import (
PopupWatchdog, WatchdogRule, default_popup_watchdog,
Expand Down Expand Up @@ -874,6 +876,7 @@ def start_autocontrol_gui(*args, **kwargs):
"LatencyDigest", "exact_percentiles",
"Bulkhead", "BulkheadFullError", "next_delay", "parse_ratelimit",
"parse_retry_after",
"Cassette", "CassetteMissError",
# MCP server
"AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt",
"MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool",
Expand Down
13 changes: 13 additions & 0 deletions je_auto_control/gui/script_builder/command_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1593,6 +1593,19 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None:
),
description="Server-advised wait (seconds) from an HTTP response; {delay}.",
))
specs.append(CommandSpec(
"AC_http_replay", "Data", "HTTP Cassette: Replay",
fields=(
FieldSpec("cassette", FieldType.STRING,
placeholder='{"interactions": [{"request": {...}, '
'"response": {...}}]}'),
FieldSpec("url", FieldType.STRING,
placeholder="https://api.example.com/users/1"),
FieldSpec("method", FieldType.STRING, optional=True,
placeholder="GET"),
),
description="Replay a recorded HTTP response from a cassette (no network).",
))
specs.append(CommandSpec(
"AC_rate_limit", "Flow", "Rate Limit (Token Bucket)",
fields=(
Expand Down
15 changes: 15 additions & 0 deletions je_auto_control/utils/executor/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2957,6 +2957,20 @@ def _retry_after(response: Any) -> Dict[str, Any]:
return {"delay": next_delay(response)}


def _http_replay(cassette: Any, url: str,
method: str = "GET") -> Dict[str, Any]:
"""Adapter: replay a recorded HTTP response from a cassette (no network)."""
import json
from je_auto_control.utils.http_cassette import Cassette
if isinstance(cassette, str):
cassette = json.loads(cassette)
interactions = (cassette.get("interactions", [])
if isinstance(cassette, dict) else cassette)
response = Cassette(interactions).replay(
{"method": str(method).upper(), "url": url})
return {"response": response}


def _percentiles(samples: Any, qs: Any = None) -> Dict[str, Any]:
"""Adapter: exact percentiles of a numeric sample list (or JSON string)."""
import json
Expand Down Expand Up @@ -4032,6 +4046,7 @@ def __init__(self):
"AC_percentiles": _percentiles,
"AC_bulkhead_run": _bulkhead_run,
"AC_retry_after": _retry_after,
"AC_http_replay": _http_replay,
"AC_unified_diff": _unified_diff,
"AC_apply_unified": _apply_unified,
"AC_three_way_merge": _three_way_merge,
Expand Down
6 changes: 6 additions & 0 deletions je_auto_control/utils/http_cassette/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Record / replay HTTP interactions for deterministic offline API tests."""
from je_auto_control.utils.http_cassette.http_cassette import (
Cassette, CassetteMissError,
)

__all__ = ["Cassette", "CassetteMissError"]
110 changes: 110 additions & 0 deletions je_auto_control/utils/http_cassette/http_cassette.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Record and replay HTTP interactions for deterministic, offline API tests.

The HTTP client hardcoded its ``urllib`` transport, so a flow that drives an
API could not be re-run without the live server. The client now exposes a
``build_call`` / ``urllib_transport`` seam; this module layers a VCR-style
cassette on top: **replay** returns a recorded response for a matching request
(pure, no network — the CI-valuable half), while **recording** is a thin
pass-through over a live transport.

Pure standard library (``json``); imports no ``PySide6``.
"""
import json
from pathlib import Path
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence

from je_auto_control.utils.exception.exceptions import AutoControlException

Transport = Callable[[Mapping[str, Any]], Dict[str, Any]]


class CassetteMissError(AutoControlException):
"""No recorded interaction matched the request during replay."""


def _decode_body(body: Any) -> Optional[str]:
if body is None:
return None
if isinstance(body, bytes):
return body.decode("utf-8", errors="replace")
return str(body)


def _request_view(call: Mapping[str, Any]) -> Dict[str, Any]:
return {"method": str(call.get("method", "GET")).upper(),
"url": call.get("url"),
"headers": dict(call.get("headers") or {}),
"body": _decode_body(call.get("body"))}


def _matches(recorded: Mapping[str, Any], call: Mapping[str, Any],
match_on: Sequence[str]) -> bool:
view = _request_view(call)
for field in match_on:
if field == "method":
if recorded.get("method") != view["method"]:
return False
elif field == "url":
if recorded.get("url") != view["url"]:
return False
elif field == "body":
if recorded.get("body") != view["body"]:

Check warning on line 51 in je_auto_control/utils/http_cassette/http_cassette.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Merge this if statement with the enclosing one.

See more on https://sonarcloud.io/project/issues?id=Integration-Automation_AutoControlGUI&issues=AZ7qXNpAupWwgW3Y1xgt&open=AZ7qXNpAupWwgW3Y1xgt&pullRequest=283
return False
return True


class Cassette:
"""A set of recorded request/response interactions (VCR-style)."""

def __init__(self,
interactions: Optional[Sequence[Mapping[str, Any]]] = None) -> None:
self._interactions: List[Dict[str, Any]] = [
dict(item) for item in (interactions or [])]

@property
def interactions(self) -> List[Dict[str, Any]]:
"""The recorded interactions."""
return self._interactions

@classmethod
def load(cls, path: str) -> "Cassette":
"""Load a cassette from a JSON file."""
data = json.loads(Path(path).read_text(encoding="utf-8"))
return cls(data.get("interactions", []))

def save(self, path: str) -> str:
"""Write the cassette to ``path`` as JSON; return the path."""
out = Path(path)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(
{"version": 1, "interactions": self._interactions}, indent=2),
encoding="utf-8")
return str(out)

def record(self, call: Mapping[str, Any],
response: Mapping[str, Any]) -> None:
"""Append one request/response interaction."""
self._interactions.append({"request": _request_view(call),
"response": dict(response)})

def replay(self, call: Mapping[str, Any], *,
match_on: Sequence[str] = ("method", "url")) -> Dict[str, Any]:
"""Return the recorded response for a matching ``call`` (no network)."""
for interaction in self._interactions:
if _matches(interaction.get("request", {}), call, match_on):
return dict(interaction["response"])
raise CassetteMissError(
f"no recorded interaction for {call.get('method')} {call.get('url')}")

def replay_transport(self, *,
match_on: Sequence[str] = ("method", "url")) -> Transport:
"""Return a transport that replays from this cassette."""
return lambda call: self.replay(call, match_on=match_on)

def recording_transport(self, inner: Transport) -> Transport:
"""Return a transport that records ``inner``'s live responses."""
def transport(call: Mapping[str, Any]) -> Dict[str, Any]:
response = inner(call)
self.record(call, response)
return response
return transport
6 changes: 4 additions & 2 deletions je_auto_control/utils/http_client/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""Dependency-free HTTP(S) client for AutoControl action steps."""
from je_auto_control.utils.http_client.http_client import http_request
from je_auto_control.utils.http_client.http_client import (
build_call, http_request, urllib_transport,
)

__all__ = ["http_request"]
__all__ = ["build_call", "http_request", "urllib_transport"]
Loading
Loading