reliablekit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
reliable/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """reliable —— 给 LLM/Agent 调用的超时 + 退避 + 熔断,重试风暴防护是一等公民。"""
2
+
3
+ from .backoff import exp_backoff
4
+ from .breaker import CircuitBreaker, State
5
+ from .decorator import reliable
6
+ from .exceptions import CircuitOpenError, ReliableError
7
+
8
+ __all__ = [
9
+ "CircuitBreaker",
10
+ "CircuitOpenError",
11
+ "ReliableError",
12
+ "State",
13
+ "exp_backoff",
14
+ "reliable",
15
+ ]
16
+
17
+ __version__ = "0.1.0"
reliable/backoff.py ADDED
@@ -0,0 +1,31 @@
1
+ """指数退避 + 抖动。
2
+
3
+ W1 范围:确定性指数退避,抖动在 ±jitter 区间内均匀取值。
4
+ 设计动机(来自大规模分布式系统的故障检测经验):不带抖动的固定退避会让
5
+ 大量客户端在同一时刻同步重试,形成重试风暴。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import random
11
+ from typing import Callable
12
+
13
+
14
+ def exp_backoff(
15
+ attempt: int,
16
+ base: float = 0.2,
17
+ factor: float = 2.0,
18
+ jitter: float = 0.1,
19
+ rng: Callable[[], float] = random.random,
20
+ ) -> float:
21
+ """计算第 attempt 次重试前的等待秒数。
22
+
23
+ attempt 从 0 开始;delay = base * factor**attempt,再乘以
24
+ (1 + jitter * U(-1, 1)) 的抖动因子。jitter=0 时结果确定。
25
+ """
26
+ if attempt < 0:
27
+ raise ValueError("attempt must be >= 0")
28
+ if base < 0 or factor <= 0:
29
+ raise ValueError("base must be >= 0 and factor must be > 0")
30
+ delay = base * (factor**attempt)
31
+ return max(0.0, delay * (1.0 + jitter * (rng() * 2.0 - 1.0)))
reliable/breaker.py ADDED
@@ -0,0 +1,147 @@
1
+ """滑动窗口熔断器:CLOSED -> OPEN -> HALF_OPEN -> CLOSED 三态状态机。
2
+
3
+ 设计取舍(来自一次生产级故障复盘,写在这里防止将来自己忘):
4
+
5
+ 1. 熔断判定看"滑动窗口失败率",不看连续失败次数。
6
+ 连续 N 次失败才熔断的策略对"偶发抖动 + 低流量"场景过于迟钝,
7
+ 对"突发风暴"又过于敏感;窗口失败率对两者都更稳。
8
+
9
+ 2. HALF_OPEN 的放行不设并发名额闸门(probe 语义 = 连续成功 N 次才闭合)。
10
+ 名额闸门在探针调用挂死(未走到 record)时会死锁在 half_open,
11
+ 一次生产故障复盘里"idle 看门狗假阳性"就是同类坑。v0.1 用简单语义换正确性,
12
+ 并发闸门放路线图。
13
+
14
+ 3. 时钟与随机源可注入,测试不 sleep。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import random
20
+ import threading
21
+ import time
22
+ from collections import deque
23
+ from enum import Enum
24
+ from typing import Callable
25
+
26
+ from .exceptions import CircuitOpenError
27
+
28
+
29
+ class State(Enum):
30
+ CLOSED = "closed"
31
+ OPEN = "open"
32
+ HALF_OPEN = "half_open"
33
+
34
+
35
+ class CircuitBreaker:
36
+ """滑动窗口失败率熔断器。
37
+
38
+ 参数:
39
+ failure_rate: 窗口内失败率达到该值即熔断(0 < r <= 1)。
40
+ window: 滑动窗口样本数(成功+失败),deque 自动滑动。
41
+ cooldown: OPEN 后等待多少秒才允许半开探测。
42
+ probe: HALF_OPEN 下需要连续成功多少次才回到 CLOSED。
43
+ clock/rng: 可注入时钟与随机源(测试用)。
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ failure_rate: float = 0.5,
49
+ window: int = 20,
50
+ cooldown: float = 30.0,
51
+ probe: int = 3,
52
+ clock: Callable[[], float] = time.monotonic,
53
+ rng: Callable[[], float] = random.random,
54
+ ) -> None:
55
+ if not 0 < failure_rate <= 1:
56
+ raise ValueError("failure_rate must be in (0, 1]")
57
+ if window < 1:
58
+ raise ValueError("window must be >= 1")
59
+ if cooldown < 0:
60
+ raise ValueError("cooldown must be >= 0")
61
+ if probe < 1:
62
+ raise ValueError("probe must be >= 1")
63
+ self.failure_rate = failure_rate
64
+ self.window = window
65
+ self.cooldown = cooldown
66
+ self.probe = probe
67
+ self._clock = clock
68
+ self._rng = rng
69
+ self._lock = threading.Lock()
70
+ self._results: deque[bool] = deque(maxlen=window)
71
+ self._state = State.CLOSED
72
+ self._opened_at: float | None = None
73
+ self._half_open_results: list[bool] = []
74
+
75
+ @property
76
+ def state(self) -> State:
77
+ with self._lock:
78
+ return self._state
79
+
80
+ def before_call(self) -> None:
81
+ """调用前检查;OPEN 且未到冷却时间时抛 CircuitOpenError。"""
82
+ with self._lock:
83
+ now = self._clock()
84
+ if self._state is State.OPEN:
85
+ if self._opened_at is None:
86
+ self._opened_at = now
87
+ elapsed = now - self._opened_at
88
+ if elapsed >= self.cooldown:
89
+ self._state = State.HALF_OPEN
90
+ self._half_open_results = []
91
+ else:
92
+ raise CircuitOpenError(
93
+ f"circuit is OPEN, retry after {self.cooldown - elapsed:.1f}s",
94
+ retry_after=self.cooldown - elapsed,
95
+ )
96
+ # CLOSED / HALF_OPEN 均放行
97
+
98
+ def record_success(self) -> None:
99
+ with self._lock:
100
+ if self._state is State.HALF_OPEN:
101
+ self._half_open_results.append(True)
102
+ if len(self._half_open_results) >= self.probe:
103
+ self._state = State.CLOSED
104
+ self._half_open_results = []
105
+ self._results.clear()
106
+ self._opened_at = None
107
+ else:
108
+ self._results.append(True)
109
+
110
+ def record_failure(self) -> None:
111
+ with self._lock:
112
+ if self._state is State.HALF_OPEN:
113
+ self._trip()
114
+ return
115
+ self._results.append(False)
116
+ if len(self._results) >= self.window:
117
+ failures = self._results.count(False)
118
+ if failures / len(self._results) >= self.failure_rate:
119
+ self._trip()
120
+
121
+ def _trip(self) -> None:
122
+ self._state = State.OPEN
123
+ self._opened_at = self._clock()
124
+ self._half_open_results = []
125
+
126
+ def stats(self) -> dict:
127
+ """返回当前状态与窗口统计,便于观测/接入 MCP 资源。
128
+
129
+ 返回字段:state(closed/open/half_open)、failure_rate_threshold、
130
+ window、window_total、window_failures、window_failure_rate、
131
+ cooldown、probe、opened_at。
132
+ """
133
+ with self._lock:
134
+ results = list(self._results)
135
+ total = len(results)
136
+ failures = results.count(False) if total else 0
137
+ return {
138
+ "state": self._state.value,
139
+ "failure_rate_threshold": self.failure_rate,
140
+ "window": self.window,
141
+ "window_total": total,
142
+ "window_failures": failures,
143
+ "window_failure_rate": (failures / total) if total else 0.0,
144
+ "cooldown": self.cooldown,
145
+ "probe": self.probe,
146
+ "opened_at": self._opened_at,
147
+ }
reliable/decorator.py ADDED
@@ -0,0 +1,102 @@
1
+ """reliable 装饰器:timeout + 指数退避重试 + 熔断 + 降级回调。
2
+
3
+ 用法::
4
+
5
+ breaker = CircuitBreaker(failure_rate=0.5, window=20, cooldown=30.0, probe=3)
6
+
7
+ @reliable(breaker=breaker, timeout=8.0, max_attempts=5,
8
+ on_open=lambda *a, **k: cache_fallback())
9
+ def call_llm(prompt: str) -> str:
10
+ ...
11
+
12
+ 已知取舍(v0.1):
13
+ - timeout 语义是"放弃等待"——底层线程仍在运行,生产上请配合
14
+ HTTP 客户端自身的超时;asyncio 原生版本在路线图。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import functools
20
+ import random
21
+ import time
22
+ from concurrent.futures import ThreadPoolExecutor
23
+ from typing import Any, Callable, TypeVar
24
+
25
+ from .backoff import exp_backoff
26
+ from .breaker import CircuitBreaker
27
+ from .exceptions import CircuitOpenError
28
+
29
+ F = TypeVar("F", bound=Callable[..., Any])
30
+
31
+
32
+ def reliable(
33
+ fn: F | None = None,
34
+ *,
35
+ breaker: CircuitBreaker | None = None,
36
+ timeout: float | None = None,
37
+ max_attempts: int = 5,
38
+ base_delay: float = 0.2,
39
+ factor: float = 2.0,
40
+ jitter: float = 0.1,
41
+ sleep: Callable[[float], None] = time.sleep,
42
+ rng: Callable[[], float] = random.random,
43
+ on_open: Callable[..., Any] | None = None,
44
+ ) -> Callable:
45
+ """把同步函数包上超时 / 重试 / 熔断 / 降级。
46
+
47
+ - 每次 attempt 前先问熔断器;OPEN 且未到冷却则抛 CircuitOpenError,
48
+ 若提供 on_open 则调用其返回值作为降级结果。
49
+ - 任何异常(含超时)都记为一次失败并触发退避;成功记为成功。
50
+ - 支持 ``@reliable`` 与 ``@reliable(...)`` 两种写法。
51
+ """
52
+ if max_attempts < 1:
53
+ raise ValueError("max_attempts must be >= 1")
54
+
55
+ def decorator(f: F) -> F:
56
+ @functools.wraps(f)
57
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
58
+ last_exc: BaseException | None = None
59
+ for attempt in range(max_attempts):
60
+ if breaker is not None:
61
+ try:
62
+ breaker.before_call()
63
+ except CircuitOpenError:
64
+ if on_open is not None:
65
+ return on_open(*args, **kwargs)
66
+ raise
67
+ try:
68
+ result = _invoke(f, args, kwargs, timeout)
69
+ except Exception as exc:
70
+ if breaker is not None:
71
+ breaker.record_failure()
72
+ last_exc = exc
73
+ if attempt < max_attempts - 1:
74
+ sleep(exp_backoff(attempt, base_delay, factor, jitter, rng))
75
+ else:
76
+ if breaker is not None:
77
+ breaker.record_success()
78
+ return result
79
+ assert last_exc is not None
80
+ raise last_exc
81
+
82
+ return wrapper # type: ignore[return-value]
83
+
84
+ if fn is not None:
85
+ return decorator(fn)
86
+ return decorator
87
+
88
+
89
+ def _invoke(
90
+ fn: Callable[..., Any],
91
+ args: tuple,
92
+ kwargs: dict,
93
+ timeout: float | None,
94
+ ) -> Any:
95
+ if timeout is None:
96
+ return fn(*args, **kwargs)
97
+ executor = ThreadPoolExecutor(max_workers=1)
98
+ try:
99
+ future = executor.submit(fn, *args, **kwargs)
100
+ return future.result(timeout=timeout)
101
+ finally:
102
+ executor.shutdown(wait=False)
reliable/exceptions.py ADDED
@@ -0,0 +1,18 @@
1
+ """reliable 的异常定义。"""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ReliableError(Exception):
7
+ """reliable 库的异常基类。"""
8
+
9
+
10
+ class CircuitOpenError(ReliableError):
11
+ """熔断器处于 OPEN(或半开名额已拒绝)时发起调用。
12
+
13
+ 携带剩余冷却秒数,调用方可据此决定等待或直接降级。
14
+ """
15
+
16
+ def __init__(self, message: str, retry_after: float | None = None) -> None:
17
+ super().__init__(message)
18
+ self.retry_after = retry_after
reliable/mcp.py ADDED
@@ -0,0 +1,222 @@
1
+ """reliable 的 MCP server 骨架。
2
+
3
+ 把库里的容错原语暴露成 LLM Agent 可直接调用的工具:
4
+ - 给每个下游依赖(如某个 LLM provider)建一个命名熔断器;
5
+ - 通过熔断器发起带超时/退避/降级的 HTTP 调用;
6
+ - 查询熔断状态、预览退避节奏。
7
+
8
+ 运行(stdio,供 Claude / mcp.so / Smithery 接入)::
9
+
10
+ pip install -e ".[mcp]"
11
+ python -m reliable.mcp
12
+ # 或 reliable-mcp(安装后由 console script 提供)
13
+
14
+ 设计取舍:server 内用进程内字典保存命名熔断器(单实例、线程安全由
15
+ CircuitBreaker 自身保证)。多副本场景需要共享状态时,把 _BREAKERS 换成
16
+ Redis/etcd 后端即可——这点与"可插拔存储后端"的设计原则同构。
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import urllib.error
23
+ import urllib.request
24
+ from typing import Any
25
+
26
+ from mcp.server.mcpserver import MCPServer
27
+
28
+ from . import CircuitBreaker, CircuitOpenError, exp_backoff, reliable
29
+
30
+ mcp = MCPServer("reliable")
31
+
32
+ # 进程内命名熔断器注册表:name -> CircuitBreaker
33
+ _BREAKERS: dict[str, CircuitBreaker] = {}
34
+
35
+
36
+ def _get_breaker(name: str) -> CircuitBreaker | None:
37
+ return _BREAKERS.get(name)
38
+
39
+
40
+ def _parse_headers(headers: str | None) -> dict[str, str]:
41
+ if not headers:
42
+ return {}
43
+ return json.loads(headers)
44
+
45
+
46
+ def _encode(body: str | None) -> bytes | None:
47
+ return body.encode("utf-8") if body is not None else None
48
+
49
+
50
+ def _http(method: str, url: str, headers: dict, payload: bytes | None, timeout: float) -> str:
51
+ req = urllib.request.Request(
52
+ url, data=payload, method=method.upper(), headers=headers
53
+ )
54
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
55
+ return resp.read().decode("utf-8", "replace")
56
+
57
+
58
+ @mcp.tool()
59
+ def create_breaker(
60
+ name: str,
61
+ failure_rate: float = 0.5,
62
+ window: int = 20,
63
+ cooldown: float = 30.0,
64
+ probe: int = 3,
65
+ ) -> dict:
66
+ """Register or replace a named circuit breaker.
67
+
68
+ Use one breaker per downstream dependency (e.g. one per LLM provider).
69
+ The breaker lives in the server process for its lifetime.
70
+
71
+ Args:
72
+ name: unique breaker id, e.g. "openai", "local-llm".
73
+ failure_rate: trip when the sliding-window failure rate >= this (0..1).
74
+ window: number of recent calls in the sliding window.
75
+ cooldown: seconds the breaker stays OPEN before probing.
76
+ probe: consecutive HALF_OPEN successes required to close.
77
+ """
78
+ _BREAKERS[name] = CircuitBreaker(
79
+ failure_rate=failure_rate, window=window, cooldown=cooldown, probe=probe
80
+ )
81
+ return {"name": name, "created": True, **_BREAKERS[name].stats()}
82
+
83
+
84
+ @mcp.tool()
85
+ def get_breaker_state(name: str) -> dict:
86
+ """Return the current state and stats of a named breaker.
87
+
88
+ Args:
89
+ name: breaker id previously created with create_breaker.
90
+
91
+ Returns state (closed/open/half_open), failure-rate threshold, window
92
+ stats, cooldown and probe config.
93
+ """
94
+ b = _get_breaker(name)
95
+ if b is None:
96
+ return {"error": f"unknown breaker: {name}"}
97
+ return {"name": name, **b.stats()}
98
+
99
+
100
+ @mcp.tool()
101
+ def compute_backoff(
102
+ attempt: int,
103
+ base_delay: float = 0.2,
104
+ factor: float = 2.0,
105
+ jitter: float = 0.1,
106
+ ) -> dict:
107
+ """Compute the exponential backoff delay for a retry attempt (pure).
108
+
109
+ No side effects — handy to preview retry pacing before committing config.
110
+
111
+ Args:
112
+ attempt: 1-based retry index.
113
+ base_delay: initial delay in seconds.
114
+ factor: growth multiplier per attempt.
115
+ jitter: +/- fraction of randomness (0 disables it).
116
+ """
117
+ delay = exp_backoff(attempt, base_delay, factor, jitter)
118
+ return {"attempt": attempt, "delay": delay}
119
+
120
+
121
+ @mcp.tool()
122
+ def reliable_http_call(
123
+ breaker: str,
124
+ url: str,
125
+ method: str = "POST",
126
+ headers: str | None = None,
127
+ body: str | None = None,
128
+ timeout: float = 8.0,
129
+ max_attempts: int = 5,
130
+ base_delay: float = 0.2,
131
+ factor: float = 2.0,
132
+ jitter: float = 0.1,
133
+ fallback: str | None = None,
134
+ ) -> dict:
135
+ """Call an HTTP endpoint through a named circuit breaker.
136
+
137
+ The breaker is auto-created with default config if the name is new.
138
+ On breaker OPEN the call short-circuits and returns `fallback` (or an
139
+ error) instead of hammering the downstream — that is the retry-storm
140
+ guard this library exists for.
141
+
142
+ Args:
143
+ breaker: breaker id (auto-created if absent).
144
+ url: target URL.
145
+ method: HTTP method (default POST).
146
+ headers: JSON string of request headers.
147
+ body: request body as string/JSON.
148
+ timeout: per-attempt timeout in seconds.
149
+ max_attempts: total tries including the first.
150
+ base_delay / factor / jitter: retry backoff config.
151
+ fallback: JSON string returned when the call cannot succeed.
152
+ """
153
+ b = _get_breaker(breaker) or _BREAKERS.setdefault(breaker, CircuitBreaker())
154
+
155
+ try:
156
+ result = reliable(
157
+ breaker=b,
158
+ timeout=timeout,
159
+ max_attempts=max_attempts,
160
+ base_delay=base_delay,
161
+ factor=factor,
162
+ jitter=jitter,
163
+ )(_http)(method, url, _parse_headers(headers), _encode(body), timeout)
164
+ return {"ok": True, "breaker": breaker, "state": b.state.value, "body": result}
165
+ except CircuitOpenError as e:
166
+ return {
167
+ "ok": False,
168
+ "breaker": breaker,
169
+ "open": True,
170
+ "retry_after": e.retry_after,
171
+ "fallback": fallback,
172
+ }
173
+ except urllib.error.HTTPError as e: # non-2xx
174
+ return {
175
+ "ok": False,
176
+ "breaker": breaker,
177
+ "state": b.state.value,
178
+ "http_status": e.code,
179
+ "error": e.reason,
180
+ }
181
+ except Exception as e: # noqa: BLE001 - surface everything else as error
182
+ return {"ok": False, "breaker": breaker, "state": b.state.value, "error": str(e)}
183
+
184
+
185
+ @mcp.resource("reliable://breaker/{name}")
186
+ def breaker_resource(name: str) -> dict:
187
+ """Expose a breaker's live stats as an MCP resource."""
188
+ b = _get_breaker(name)
189
+ if b is None:
190
+ return {"error": f"unknown breaker: {name}"}
191
+ return b.stats()
192
+
193
+
194
+ @mcp.prompt()
195
+ def diagnose_breaker(name: str) -> str:
196
+ """Build a diagnostic prompt for a named breaker's current health."""
197
+ b = _get_breaker(name)
198
+ if b is None:
199
+ return (
200
+ f"Breaker '{name}' does not exist yet. "
201
+ "Create it first with the create_breaker tool."
202
+ )
203
+ s = b.stats()
204
+ rate = s["window_failure_rate"]
205
+ thr = s["failure_rate_threshold"]
206
+ return (
207
+ f"Analyze the circuit breaker '{name}'.\n"
208
+ f"Current state: {s['state']}.\n"
209
+ f"Window failure rate: {rate:.0%} (trip threshold {thr:.0%}), "
210
+ f"window size {s['window_total']}/{s['window']}.\n"
211
+ f"Cooldown {s['cooldown']}s, probe {s['probe']}.\n"
212
+ "Is the downstream dependency healthy? Should we tighten or loosen "
213
+ "the threshold, and what action do you recommend next?"
214
+ )
215
+
216
+
217
+ def main() -> None:
218
+ mcp.run()
219
+
220
+
221
+ if __name__ == "__main__":
222
+ main()
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: reliablekit
3
+ Version: 0.1.0
4
+ Summary: Reliable LLM/Agent calls: timeout + exponential backoff + sliding-window circuit breaker, with retry-storm protection as a first-class citizen.
5
+ Author: Hou Congcong
6
+ License-Expression: MIT
7
+ Keywords: llm,retry,circuit-breaker,resilience,agent
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Provides-Extra: mcp
12
+ Requires-Dist: mcp>=1.2.0; extra == "mcp"
13
+ Dynamic: license-file
14
+
15
+ # reliable
16
+
17
+ > Python resilience for LLM/Agent calls — timeout + jitter backoff + sliding-window circuit breaker + fallback. Built to stop retry storms.
18
+
19
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
20
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org)
21
+ [![Status](https://img.shields.io/badge/status-v0.1--alpha-orange.svg)](https://github.com/GravityWell8/reliable)
22
+ <!-- mcp-name: io.github.gravitywell8/reliable -->
23
+
24
+ **给谁**:调 LLM/Agent API 的 Python 开发者。
25
+ **解决什么**:超时、限流、偶发 5xx 把 Agent 搞挂;naive retry 引发重试风暴;下游已经瘫了还在持续被打。
26
+ **怎么用**:一个装饰器,把任意同步函数包上超时 + 指数退避 + 滑动窗口熔断 + 降级回调。
27
+
28
+ ## 安装
29
+
30
+ ```bash
31
+ # 从 PyPI 安装(发布名 reliablekit,import 名仍为 reliable)
32
+ pip install reliablekit
33
+ python3 -m reliable.demo # 需先 pip install -e . 或本地运行 examples/demo.py
34
+
35
+ # 本地开发:可编辑安装(推荐)
36
+ pip install -e .
37
+ python3 examples/demo.py
38
+ python3 -m unittest discover -s tests
39
+
40
+ # 不安装,直接用 PYTHONPATH 指向 src(从仓库根目录运行)
41
+ PYTHONPATH=src python3 examples/demo.py
42
+ ```
43
+
44
+ > 发布名是 `reliablekit`(PyPI 不允许 `reliable`,因与已存在的 `reliability` 太像被防 typosquat 规则拦截);导入时仍用 `import reliable`。
45
+
46
+ ## 快速开始
47
+
48
+ ```python
49
+ from reliable import CircuitBreaker, reliable
50
+
51
+ breaker = CircuitBreaker(
52
+ failure_rate=0.5, # 滑动窗口失败率 >= 50% 即熔断
53
+ window=20, # 窗口 20 次调用
54
+ cooldown=30.0, # 熔断后冷却 30 秒
55
+ probe=3, # 半开后连续成功 3 次才闭合
56
+ )
57
+
58
+ @reliable(
59
+ breaker=breaker,
60
+ timeout=8.0, # 单次调用超时
61
+ max_attempts=5, # 含首次共 5 次尝试
62
+ base_delay=0.2, # 退避基数,factor=2 指数增长
63
+ jitter=0.1, # ±10% 抖动,防重试同步化
64
+ on_open=lambda *a, **k: cache_fallback(), # 熔断期间的降级
65
+ )
66
+ def call_llm(prompt: str) -> str:
67
+ ...
68
+ ```
69
+
70
+ 跑 demo(完整状态机流转,约 4 秒):
71
+
72
+ ```bash
73
+ python3 examples/demo.py # 已 pip install -e . 时
74
+ # 或 PYTHONPATH=src python3 examples/demo.py
75
+ ```
76
+
77
+ 跑测试(19 个用例,时钟/sleep 全注入,零真实等待):
78
+
79
+ ```bash
80
+ python3 -m unittest discover -s tests
81
+ # 或 PYTHONPATH=src python3 -m unittest discover -s tests
82
+ ```
83
+
84
+ ## 状态机
85
+
86
+ ```
87
+ 失败率 >= 阈值
88
+ CLOSED ────────────────► OPEN
89
+ ▲ │
90
+ │ probe 次连续成功 │ 冷却到期
91
+ │ ▼
92
+ └────────────────── HALF_OPEN
93
+ 任一失败 ──► 回到 OPEN
94
+ ```
95
+
96
+ - **CLOSED**:正常放行;滑动窗口失败率达到 `failure_rate` → OPEN。
97
+ - **OPEN**:直接拒绝(`CircuitOpenError`)或走 `on_open` 降级,不打下游;冷却到期 → HALF_OPEN。
98
+ - **HALF_OPEN**:放行探针调用;任一失败 → 回 OPEN(重新计时);`probe` 次连续成功 → CLOSED 并清空窗口。
99
+
100
+ ## API
101
+
102
+ | 名称 | 说明 |
103
+ |---|---|
104
+ | `reliable(fn=None, *, breaker, timeout, max_attempts, base_delay, factor, jitter, sleep, rng, on_open)` | 装饰器 / 包装函数,支持 `@reliable` 与 `@reliable(...)` |
105
+ | `CircuitBreaker(failure_rate, window, cooldown, probe, clock, rng)` | 滑动窗口熔断器,线程安全,时钟/随机源可注入 |
106
+ | `exp_backoff(attempt, base, factor, jitter, rng)` | 指数退避 + 抖动计算 |
107
+ | `CircuitOpenError(retry_after=...)` | 熔断期调用抛出,携带剩余冷却秒数 |
108
+
109
+ ## 设计取舍(为什么和别的 retry 库不一样)
110
+
111
+ 1. **熔断判定看滑动窗口失败率,不看连续失败次数。** 连续 N 次失败的策略对"偶发抖动 + 低流量"过于迟钝、对"突发风暴"过于敏感;窗口失败率对两者都稳。
112
+ 2. **重试带 ±jitter 抖动。** 无抖动的固定退避会让大量客户端同步重试——重试风暴是这套库防御的第一目标,所以抖动默认开启。
113
+ 3. **熔断期间走降级回调而不是干等。** `on_open` 让你在下游瘫痪时仍有产出(缓存/简化模型/默认值),错误被转化为降级结果。
114
+ 4. **HALF_OPEN 不设并发名额闸门。** 名额在探针调用挂死时会死锁在 half_open——这类"idle 探测假阳性/状态卡死"的坑来自真实故障复盘(下游静默卡死、探针挂死),v0.1 用"连续成功 N 次闭合"的简单语义换正确性。
115
+
116
+ ## 已知限制(v0.1)
117
+
118
+ - `timeout` 语义是"放弃等待":底层线程仍在运行,生产上请配合 HTTP 客户端自身超时。
119
+ - 仅支持同步函数;asyncio 原生版在路线图。
120
+
121
+ ## 路线图
122
+
123
+ - [x] MCP server(已发布,见下)
124
+ - [ ] asyncio 原生版(真取消,不放弃等待)
125
+ - [ ] TypeScript 版(同 API 语义)
126
+ - [ ] 指标导出:每次调用的耗时 / 尝试次数 / 熔断状态变化(可观测性对接)
127
+ - [ ] 预算熔断(token / 成本上限,共享同一熔断内核)
128
+
129
+ ## MCP Server
130
+
131
+ 把库里的容错原语暴露成 LLM Agent 可直接调用的 MCP 工具,适合"Agent 要调很多 LLM/HTTP 下游、又不想自己重写熔断"的场景。
132
+
133
+ **提供的 surface:**
134
+ - 工具 `create_breaker`:给每个下游依赖(如某 LLM provider)建一个命名熔断器。
135
+ - 工具 `reliable_http_call`:通过命名熔断器发起带超时 / 退避 / 降级的 HTTP 调用;熔断 OPEN 时直接短路返回 `fallback`,不再打下游(重试风暴防护)。
136
+ - 工具 `get_breaker_state` / `compute_backoff`:查状态、预览退避节奏。
137
+ - 资源 `reliable://breaker/{name}`:熔断器的实时 stats。
138
+ - 提示 `diagnose_breaker`:根据状态生成健康诊断 prompt。
139
+
140
+ **运行(stdio,供 Claude Desktop / Cursor 等 MCP 客户端接入):**
141
+
142
+ ```bash
143
+ pip install -e ".[mcp]"
144
+ python -m reliable.mcp # 或安装后直接用 reliable-mcp
145
+ ```
146
+
147
+ **Manifest 文件(仓库根,供注册表提交 / 自托管):** `mcp.json`、`.mcp.json`,以及可复用的 `smithery.yaml` + `Dockerfile`(供需要容器构建的注册表)。
148
+
149
+ > 状态保存在 server 进程内的 `_BREAKERS` 字典(单实例、线程安全由 `CircuitBreaker` 保证)。多副本需要共享状态时,把 `_BREAKERS` 换成 Redis/etcd 后端即可——这点与"可插拔存储后端"的设计原则同构。
150
+
151
+ ## License
152
+
153
+ MIT
@@ -0,0 +1,12 @@
1
+ reliable/__init__.py,sha256=w-5b_Mhlew0jyonuALQckEG0wVz07bFVnYL9Tw-Tzo4,427
2
+ reliable/backoff.py,sha256=E3fI6cJZKurn_9uVpiH12Wq-mKxqQlbyWjl5UjbcxhQ,1015
3
+ reliable/breaker.py,sha256=C3nl96epq6pLmLdJ6683xIiJvggXrWYDSDv-b_h3r0Y,5411
4
+ reliable/decorator.py,sha256=UAhgh83pYj9BXnePM-t4ekTFPlW7uwzGnKTyUCkUh-w,3332
5
+ reliable/exceptions.py,sha256=vmhZp8rV-7hskXOd_YD3FvxDqVcLXPRghgHrA-I_aYQ,505
6
+ reliable/mcp.py,sha256=fpSMo738Mucp5mRGxdcwXa8pLIz8DW2To7P5GIDUlYM,7137
7
+ reliablekit-0.1.0.dist-info/licenses/LICENSE,sha256=9UT9WrKfOxvEtEx5dH-QvSQQIoi6JE1qGp7y2p1NqDw,1078
8
+ reliablekit-0.1.0.dist-info/METADATA,sha256=Scl1FlSnyb6kq77Min9Zckwey414tMn47HHLvq3H-Ek,7013
9
+ reliablekit-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ reliablekit-0.1.0.dist-info/entry_points.txt,sha256=q0BAyQznb6Vl1tJ1BTPtxcHdt4hGoHml7XQZhL1-v_A,51
11
+ reliablekit-0.1.0.dist-info/top_level.txt,sha256=wzruBZKWPHfo8MId3YiH7KcGcFviVQ0Ea9FiubWoiMI,9
12
+ reliablekit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ reliable-mcp = reliable.mcp:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 reliable contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ reliable