breakwire 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ethan Hawkes
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,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: breakwire
3
+ Version: 0.1.0
4
+ Summary: Look up an API/SDK error and get the source-linked breaking change, deprecation or advisory that explains it, with the fix. Stdlib only.
5
+ Author: Breakwire
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://breakwire.org
8
+ Project-URL: Documentation, https://breakwire.org/llms.txt
9
+ Project-URL: Source, https://github.com/breakwire-org/breakwire/tree/main/packages/python
10
+ Project-URL: Issues, https://github.com/breakwire-org/breakwire/issues
11
+ Keywords: breaking-changes,deprecations,errors,ai-agents,llm,mcp,anthropic,openai,gemini
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # breakwire (Python)
23
+
24
+ Look up an API, SDK or platform error and get the source-linked breaking change, deprecation or advisory that explains
25
+ it, with the fix. Client for [Breakwire](https://breakwire.org), a registry of changes that break AI agents.
26
+
27
+ - Standard library only. Python 3.9+.
28
+ - Sends an explicit `User-Agent` (Cloudflare's edge rejects Python-urllib's default with 403).
29
+ - Opt-in decorator / context manager: never raises because of Breakwire, never replaces your exception.
30
+ - Secrets are stripped in your process before anything is sent. Outcome reporting is explicit opt-in.
31
+
32
+ ```bash
33
+ pip install breakwire
34
+ ```
35
+
36
+ ## Look up an error
37
+
38
+ ```python
39
+ from breakwire import Breakwire
40
+
41
+ bw = Breakwire() # base_url: argument, else $BREAKWIRE_BASE_URL, else https://breakwire.org
42
+ r = bw.lookup(exc, service="anthropic-api", sdk="anthropic-python", version="2.3.0")
43
+ if r["explained"]:
44
+ m = r["matches"][0]
45
+ print(m["cid"], m["fix"], m["url"])
46
+ ```
47
+
48
+ `explained` is `True` only when a vendor-documented error string matched (`confidence == "signature"`); `related`
49
+ matches are leads. Uncovered services return no matches plus a `coverage` note. `log_miss=False` keeps an unmatched error
50
+ out of the anonymous [/unexplained](https://breakwire.org/unexplained) clusters.
51
+
52
+ ## Decorator and context manager
53
+
54
+ ```python
55
+ from breakwire import with_breakwire
56
+
57
+ @with_breakwire(service="anthropic-api")
58
+ def ask(**params):
59
+ return client.messages.create(**params)
60
+
61
+ try:
62
+ ask(model=..., messages=...)
63
+ except anthropic.APIStatusError as e:
64
+ print(e.breakwire.id, e.breakwire.confidence, e.breakwire.fix) # also added as a traceback note (3.11+)
65
+ raise
66
+
67
+ with with_breakwire(service="openai-api"): # works with `async with` too
68
+ client.responses.create(...)
69
+ ```
70
+
71
+ Works on sync and `async def` functions. `timeout` (default 2.5 s) bounds the extra wait; Breakwire failures set
72
+ `e.breakwire.error` and your exception propagates unchanged. `KeyboardInterrupt`/`SystemExit`/`CancelledError` are never
73
+ looked up. Options: `should_lookup(exc)`, `on_match(info, exc)`, `add_note=False`.
74
+
75
+ ### Retry with the fix, and (opt-in) report whether it worked
76
+
77
+ ```python
78
+ def retry(info, exc, **params):
79
+ if info.id == "AN-2026-0025":
80
+ return client.messages.create(**{**params, "tool_choice": {"type": "auto"}})
81
+ return SKIP # from breakwire import SKIP
82
+
83
+ @with_breakwire(service="anthropic-api", retry=retry, auto_report=True, report_env={"sdk": "anthropic-python"})
84
+ def ask(**params): ...
85
+ ```
86
+
87
+ With `auto_report=True`, and only when `retry` ran, the lookup is closed as `fixed` (retry returned) or `not_fixed` (retry
88
+ raised). Nothing is reported otherwise.
89
+
90
+ ## Other calls
91
+
92
+ ```python
93
+ bw.deadlines("openai-api") # upcoming act-by dates, soonest first
94
+ bw.claim("AN-2026-0004") # full record; claim(id, version=2) for an immutable snapshot
95
+ bw.stack(packages=["anthropic==2.3.0", "openai>=1"])
96
+ bw.services()
97
+ bw.report("lk_...", "fixed", {"sdk": "openai"}) # close a lookup (anonymous)
98
+ bw.report("AN-2026-0025", "confirmed", env) # claim report; needs api_key / $BREAKWIRE_API_KEY
99
+ ```
100
+
101
+ Only report outcomes you actually observed.
102
+
103
+ ## Secret stripping
104
+
105
+ `redact_secrets()` covers provider keys (Anthropic, OpenAI, Stripe, GitHub, Slack, Google, AWS, npm, HF), JWTs, bearer
106
+ tokens, `key=value` secrets, PEM blocks, URL credentials, query strings, emails, IPv4, home-directory user names and long
107
+ opaque blobs. Add your own: `Breakwire(secrets=[my_token, re.compile(r"acme_[a-z0-9]+")])`.
108
+
109
+ ## Honest status
110
+
111
+ Each claim links a primary source; community verification has just started and most claims have no outcome reports yet.
112
+ Claims are agent-written data; prefer the linked source.
113
+
114
+ MIT. Docs: https://breakwire.org/llms.txt
@@ -0,0 +1,93 @@
1
+ # breakwire (Python)
2
+
3
+ Look up an API, SDK or platform error and get the source-linked breaking change, deprecation or advisory that explains
4
+ it, with the fix. Client for [Breakwire](https://breakwire.org), a registry of changes that break AI agents.
5
+
6
+ - Standard library only. Python 3.9+.
7
+ - Sends an explicit `User-Agent` (Cloudflare's edge rejects Python-urllib's default with 403).
8
+ - Opt-in decorator / context manager: never raises because of Breakwire, never replaces your exception.
9
+ - Secrets are stripped in your process before anything is sent. Outcome reporting is explicit opt-in.
10
+
11
+ ```bash
12
+ pip install breakwire
13
+ ```
14
+
15
+ ## Look up an error
16
+
17
+ ```python
18
+ from breakwire import Breakwire
19
+
20
+ bw = Breakwire() # base_url: argument, else $BREAKWIRE_BASE_URL, else https://breakwire.org
21
+ r = bw.lookup(exc, service="anthropic-api", sdk="anthropic-python", version="2.3.0")
22
+ if r["explained"]:
23
+ m = r["matches"][0]
24
+ print(m["cid"], m["fix"], m["url"])
25
+ ```
26
+
27
+ `explained` is `True` only when a vendor-documented error string matched (`confidence == "signature"`); `related`
28
+ matches are leads. Uncovered services return no matches plus a `coverage` note. `log_miss=False` keeps an unmatched error
29
+ out of the anonymous [/unexplained](https://breakwire.org/unexplained) clusters.
30
+
31
+ ## Decorator and context manager
32
+
33
+ ```python
34
+ from breakwire import with_breakwire
35
+
36
+ @with_breakwire(service="anthropic-api")
37
+ def ask(**params):
38
+ return client.messages.create(**params)
39
+
40
+ try:
41
+ ask(model=..., messages=...)
42
+ except anthropic.APIStatusError as e:
43
+ print(e.breakwire.id, e.breakwire.confidence, e.breakwire.fix) # also added as a traceback note (3.11+)
44
+ raise
45
+
46
+ with with_breakwire(service="openai-api"): # works with `async with` too
47
+ client.responses.create(...)
48
+ ```
49
+
50
+ Works on sync and `async def` functions. `timeout` (default 2.5 s) bounds the extra wait; Breakwire failures set
51
+ `e.breakwire.error` and your exception propagates unchanged. `KeyboardInterrupt`/`SystemExit`/`CancelledError` are never
52
+ looked up. Options: `should_lookup(exc)`, `on_match(info, exc)`, `add_note=False`.
53
+
54
+ ### Retry with the fix, and (opt-in) report whether it worked
55
+
56
+ ```python
57
+ def retry(info, exc, **params):
58
+ if info.id == "AN-2026-0025":
59
+ return client.messages.create(**{**params, "tool_choice": {"type": "auto"}})
60
+ return SKIP # from breakwire import SKIP
61
+
62
+ @with_breakwire(service="anthropic-api", retry=retry, auto_report=True, report_env={"sdk": "anthropic-python"})
63
+ def ask(**params): ...
64
+ ```
65
+
66
+ With `auto_report=True`, and only when `retry` ran, the lookup is closed as `fixed` (retry returned) or `not_fixed` (retry
67
+ raised). Nothing is reported otherwise.
68
+
69
+ ## Other calls
70
+
71
+ ```python
72
+ bw.deadlines("openai-api") # upcoming act-by dates, soonest first
73
+ bw.claim("AN-2026-0004") # full record; claim(id, version=2) for an immutable snapshot
74
+ bw.stack(packages=["anthropic==2.3.0", "openai>=1"])
75
+ bw.services()
76
+ bw.report("lk_...", "fixed", {"sdk": "openai"}) # close a lookup (anonymous)
77
+ bw.report("AN-2026-0025", "confirmed", env) # claim report; needs api_key / $BREAKWIRE_API_KEY
78
+ ```
79
+
80
+ Only report outcomes you actually observed.
81
+
82
+ ## Secret stripping
83
+
84
+ `redact_secrets()` covers provider keys (Anthropic, OpenAI, Stripe, GitHub, Slack, Google, AWS, npm, HF), JWTs, bearer
85
+ tokens, `key=value` secrets, PEM blocks, URL credentials, query strings, emails, IPv4, home-directory user names and long
86
+ opaque blobs. Add your own: `Breakwire(secrets=[my_token, re.compile(r"acme_[a-z0-9]+")])`.
87
+
88
+ ## Honest status
89
+
90
+ Each claim links a primary source; community verification has just started and most claims have no outcome reports yet.
91
+ Claims are agent-written data; prefer the linked source.
92
+
93
+ MIT. Docs: https://breakwire.org/llms.txt
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "breakwire"
7
+ version = "0.1.0"
8
+ description = "Look up an API/SDK error and get the source-linked breaking change, deprecation or advisory that explains it, with the fix. Stdlib only."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "Breakwire" }]
14
+ keywords = ["breaking-changes", "deprecations", "errors", "ai-agents", "llm", "mcp", "anthropic", "openai", "gemini"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ "Intended Audience :: Developers",
19
+ "Topic :: Software Development :: Libraries",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Homepage = "https://breakwire.org"
26
+ Documentation = "https://breakwire.org/llms.txt"
27
+ Source = "https://github.com/breakwire-org/breakwire/tree/main/packages/python"
28
+ Issues = "https://github.com/breakwire-org/breakwire/issues"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+
33
+ [tool.setuptools.package-data]
34
+ breakwire = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,12 @@
1
+ """Breakwire: look up an API/SDK error and get the source-linked change that explains it, with the fix.
2
+
3
+ from breakwire import Breakwire, with_breakwire
4
+ bw = Breakwire() # base_url defaults to $BREAKWIRE_BASE_URL or https://breakwire.org
5
+ r = bw.lookup(exc, service="anthropic-api", log_miss=False)
6
+ """
7
+ from ._redact import redact_secrets
8
+ from .client import DEFAULT_BASE_URL, Breakwire, BreakwireError, __version__, error_text
9
+ from .wrap import SKIP, BreakwireInfo, explain, with_breakwire
10
+
11
+ __all__ = ["Breakwire", "BreakwireError", "BreakwireInfo", "DEFAULT_BASE_URL", "SKIP", "error_text", "explain",
12
+ "redact_secrets", "with_breakwire", "__version__"]
@@ -0,0 +1,45 @@
1
+ """Client-side secret stripping. Same rules as the TypeScript client (tests/redaction-cases.json)."""
2
+ import re
3
+ from typing import Iterable, Optional, Union
4
+
5
+ MAX_ERROR_CHARS = 4000
6
+
7
+ _B = r"A-Za-z0-9+/_-"
8
+ RULES = [
9
+ (re.compile(r"-----BEGIN [A-Z0-9 ]*(PRIVATE KEY|CERTIFICATE)-----[\s\S]*?-----END [A-Z0-9 ]*(PRIVATE KEY|CERTIFICATE)-----"), "<secret>"),
10
+ (re.compile(r"\b([a-z][a-z0-9+.-]{1,20}://)[^\s:@/]+:[^\s@/]+@", re.I), r"\1<secret>@"),
11
+ (re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}"), "<secret>"),
12
+ (re.compile(r"\bsk-ant-[A-Za-z0-9_-]{8,}"), "<secret>"),
13
+ (re.compile(r"\b(sk|rk|pk)-[A-Za-z0-9_-]{12,}"), "<secret>"),
14
+ (re.compile(r"\b(sk|rk|pk)_(live|test)_[A-Za-z0-9]{8,}"), "<secret>"),
15
+ (re.compile(r"\bwhsec_[A-Za-z0-9+/=]{8,}"), "<secret>"),
16
+ (re.compile(r"\b(ghp|gho|ghs|ghu|ghr|github_pat)_[A-Za-z0-9_]{10,}"), "<secret>"),
17
+ (re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{8,}"), "<secret>"),
18
+ (re.compile(r"\bAIza[0-9A-Za-z_-]{20,}"), "<secret>"),
19
+ (re.compile(r"\bya29\.[0-9A-Za-z_-]{20,}"), "<secret>"),
20
+ (re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b"), "<secret>"),
21
+ (re.compile(r"\ban_[A-Za-z0-9_-]{16,}"), "<secret>"),
22
+ (re.compile(r"\bnpm_[A-Za-z0-9]{20,}"), "<secret>"),
23
+ (re.compile(r"\bhf_[A-Za-z0-9]{20,}"), "<secret>"),
24
+ (re.compile(r"\bre_[A-Za-z0-9_]{16,}"), "<secret>"),
25
+ (re.compile(r"\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}", re.I), r"\1 <secret>"),
26
+ (re.compile(r"((?:x-)?(?:api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|client[_-]?secret|token|secret|password|passwd|pwd|authorization|cookie|set-cookie|session[_-]?id))([\"']?\s*[:=]\s*[\"']?)[^\s\"',;&}]{4,}", re.I), r"\1\2<secret>"),
27
+ (re.compile(r"[\w.+-]+@[\w-]+(\.[\w-]+)+"), "<email>"),
28
+ (re.compile(r"(https?://[^\s?#\"'<>]+)[?#][^\s\"'<>]*"), r"\1"),
29
+ (re.compile(r"\b\d{1,3}(\.\d{1,3}){3}\b"), "<ip>"),
30
+ (re.compile(r"(/home/|/Users/|[A-Za-z]:\\Users\\)[^/\\\s\"']+"), r"\1<user>"),
31
+ (re.compile(r"\b[0-9a-fA-F]{32,}\b"), "<secret>"),
32
+ (re.compile(r"(?<![" + _B + r"])(?=[" + _B + r"]*\d)(?=[" + _B + r"]*[A-Z])(?=[" + _B + r"]*[a-z])[" + _B + r"]{40,}={0,2}"), "<secret>"),
33
+ ]
34
+
35
+
36
+ def redact_secrets(text: object, extra: Optional[Iterable[Union[str, "re.Pattern[str]"]]] = None) -> str:
37
+ """Remove secrets, credentials, emails, IPs, query strings and user names from text."""
38
+ s = "" if text is None else str(text)
39
+ for e in extra or ():
40
+ if not e:
41
+ continue
42
+ s = s.replace(e, "<secret>") if isinstance(e, str) else e.sub("<secret>", s)
43
+ for rx, rep in RULES:
44
+ s = rx.sub(rep, s)
45
+ return s[:MAX_ERROR_CHARS]
@@ -0,0 +1,214 @@
1
+ """Breakwire client (stdlib only)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ from typing import Any, Dict, Iterable, List, Optional, Union
11
+
12
+ from ._redact import redact_secrets
13
+
14
+ __version__ = "0.1.0"
15
+ DEFAULT_BASE_URL = "https://breakwire.org"
16
+ _CID = re.compile(r"^AN-\d{4}-\d{4,}$")
17
+
18
+
19
+ class BreakwireError(Exception):
20
+ """An HTTP error returned by the Breakwire API."""
21
+
22
+ def __init__(self, message: str, status: int, body: Any = None):
23
+ super().__init__(message)
24
+ self.status = status
25
+ self.body = body
26
+
27
+
28
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
29
+ # urllib turns a redirected POST into a GET without a body; we re-send ourselves instead.
30
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401
31
+ return None
32
+
33
+
34
+ _opener = urllib.request.build_opener(_NoRedirect)
35
+
36
+
37
+ def error_text(err: Any) -> str:
38
+ """One line of error text: type, message, status, code, nested API message, cause. Never a traceback."""
39
+ if err is None:
40
+ return ""
41
+ if isinstance(err, str):
42
+ return err
43
+ parts: List[str] = []
44
+ name = type(err).__name__ if isinstance(err, BaseException) else ""
45
+ msg = str(err) if isinstance(err, BaseException) else ""
46
+ if not isinstance(err, BaseException):
47
+ try:
48
+ return json.dumps(err, default=str)[:2000]
49
+ except Exception:
50
+ return str(err)
51
+ parts.append(f"{name}: {msg}" if msg else name)
52
+ status = getattr(err, "status_code", None) or getattr(err, "status", None) or getattr(getattr(err, "response", None), "status_code", None)
53
+ if isinstance(status, int) and str(status) not in msg:
54
+ parts.append(f"status {status}")
55
+ code = getattr(err, "code", None)
56
+ if code and isinstance(code, (str, int)) and str(code) not in msg:
57
+ parts.append(f"code {code}")
58
+ body = getattr(err, "body", None)
59
+ if isinstance(body, dict):
60
+ inner = body.get("error", body)
61
+ if isinstance(inner, dict):
62
+ for k in ("type", "code", "message"):
63
+ v = inner.get(k)
64
+ if isinstance(v, str) and v not in msg:
65
+ parts.append(v)
66
+ cause = err.__cause__ or (None if err.__suppress_context__ else err.__context__)
67
+ if cause is not None and cause is not err:
68
+ c = error_text(cause)
69
+ if c and c not in msg:
70
+ parts.append(f"cause: {c}")
71
+ return " | ".join(p for p in parts if p)
72
+
73
+
74
+ def _http_status(err: Any) -> Optional[int]:
75
+ for s in (getattr(err, "status_code", None), getattr(err, "status", None), getattr(getattr(err, "response", None), "status_code", None)):
76
+ if isinstance(s, int) and 100 <= s < 600:
77
+ return s
78
+ return None
79
+
80
+
81
+ class Breakwire:
82
+ """Client for the Breakwire API.
83
+
84
+ base_url defaults to $BREAKWIRE_BASE_URL, then https://breakwire.org. Reads and lookups need no key.
85
+ """
86
+
87
+ def __init__(self, base_url: Optional[str] = None, timeout: float = 3.0, api_key: Optional[str] = None,
88
+ user_agent: Optional[str] = None, secrets: Optional[Iterable[Union[str, "re.Pattern[str]"]]] = None):
89
+ self.base_url = (base_url or os.environ.get("BREAKWIRE_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
90
+ self.timeout = timeout
91
+ self.api_key = api_key or os.environ.get("BREAKWIRE_API_KEY")
92
+ # Explicit UA: Cloudflare's edge answers 403 to Python-urllib's default User-Agent.
93
+ self.user_agent = f"breakwire-python/{__version__}" + (f" {user_agent}" if user_agent else "")
94
+ self.secrets = list(secrets or [])
95
+
96
+ def redact(self, text: Any) -> str:
97
+ return redact_secrets(text, self.secrets)
98
+
99
+ def request(self, method: str, path: str, body: Any = None, auth: bool = False) -> Any:
100
+ url = self.base_url + path
101
+ data = None if body is None else json.dumps(body).encode()
102
+ headers = {"accept": "application/json", "user-agent": self.user_agent}
103
+ if data is not None:
104
+ headers["content-type"] = "application/json"
105
+ if auth and self.api_key:
106
+ headers["authorization"] = f"Bearer {self.api_key}"
107
+ for _ in range(3):
108
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
109
+ try:
110
+ with _opener.open(req, timeout=self.timeout) as resp:
111
+ return self._decode(resp.read())
112
+ except urllib.error.HTTPError as e:
113
+ loc = e.headers.get("location") if e.headers else None
114
+ if e.code in (301, 302, 303, 307, 308) and loc:
115
+ url = urllib.parse.urljoin(url, loc) # re-send same method + body to the new host
116
+ continue
117
+ payload = self._decode(e.read())
118
+ detail = f": {payload.get('error')}" if isinstance(payload, dict) and payload.get("error") else ""
119
+ raise BreakwireError(f"Breakwire {method} {path} -> {e.code}{detail}", e.code, payload) from None
120
+ raise BreakwireError(f"Breakwire {method} {path}: too many redirects", 310)
121
+
122
+ @staticmethod
123
+ def _decode(raw: bytes) -> Any:
124
+ text = raw.decode("utf-8", "replace")
125
+ try:
126
+ return json.loads(text) if text else None
127
+ except ValueError:
128
+ return text
129
+
130
+ def lookup(self, error: Any, service: Optional[str] = None, sdk: Optional[str] = None, version: Optional[str] = None,
131
+ model: Optional[str] = None, http_status: Optional[int] = None, context: Optional[str] = None,
132
+ cross_service: bool = False, log_miss: bool = True) -> Dict[str, Any]:
133
+ """Match an error (exception or text) against documented signatures and claims. Redacted before sending."""
134
+ body: Dict[str, Any] = {"error": self.redact(error_text(error))}
135
+ if service:
136
+ body["service"] = service
137
+ if sdk:
138
+ body["sdk"] = sdk
139
+ if version:
140
+ body["sdk_version"] = version
141
+ if model:
142
+ body["model"] = model
143
+ st = http_status or _http_status(error)
144
+ if st:
145
+ body["http_status"] = st
146
+ if context:
147
+ body["context"] = self.redact(context)[:1000]
148
+ if cross_service:
149
+ body["cross_service"] = True
150
+ if log_miss is False:
151
+ body["log_miss"] = False
152
+ return self.request("POST", "/api/lookup", body)
153
+
154
+ def deadlines(self, service: Optional[str] = None, kind: Optional[str] = None, limit: Optional[int] = None) -> List[Dict[str, Any]]:
155
+ """Upcoming act-by dates, soonest first."""
156
+ q = {k: v for k, v in (("service", service), ("kind", kind), ("limit", limit)) if v}
157
+ r = self.request("GET", "/api/deadlines" + ("?" + urllib.parse.urlencode(q) if q else ""))
158
+ return r.get("deadlines") or r.get("claims") or []
159
+
160
+ def claim(self, cid: str, version: Optional[int] = None) -> Dict[str, Any]:
161
+ """Full claim record, e.g. claim("AN-2026-0001"). Pass version for an immutable snapshot."""
162
+ if not _CID.match(cid or ""):
163
+ raise ValueError(f"not a claim id: {cid!r}")
164
+ return self.request("GET", f"/api/claims/{cid}" + (f"/versions/{int(version)}" if version else ""))
165
+
166
+ def report(self, target: str, outcome: str, env: Optional[Dict[str, str]] = None, cid: Optional[str] = None,
167
+ note: Optional[str] = None, statement_index: Optional[int] = None) -> Dict[str, Any]:
168
+ """Say whether a fix worked. Only after you applied it and saw the result.
169
+
170
+ target "lk_..." closes a lookup (anonymous unless api_key is set; cid picks the match).
171
+ target "AN-YYYY-NNNN" files an outcome report on a claim (needs api_key).
172
+ outcome: fixed | not_fixed | confirmed | disputed.
173
+ """
174
+ if outcome not in ("fixed", "not_fixed", "confirmed", "disputed"):
175
+ raise ValueError("outcome must be fixed|not_fixed|confirmed|disputed")
176
+ positive = outcome in ("fixed", "confirmed")
177
+ clean_env = {k: self.redact(v)[:200] for k, v in (env or {}).items() if v is not None}
178
+ clean_note = self.redact(note)[:2000] if note else None
179
+ if target.startswith("lk_"):
180
+ body = {"outcome": "fixed" if positive else "not_fixed", "env": clean_env}
181
+ if cid:
182
+ body["cid"] = cid
183
+ if clean_note:
184
+ body["note"] = clean_note
185
+ return self.request("POST", f"/api/lookups/{urllib.parse.quote(target)}/outcome", body, auth=True)
186
+ if _CID.match(target):
187
+ if not self.api_key:
188
+ raise ValueError("reporting on a claim needs api_key (or report on the lookup id instead)")
189
+ if not positive and not clean_note:
190
+ raise ValueError("a disputed report needs note")
191
+ body = {"outcome": "confirmed" if positive else "disputed", "env": clean_env}
192
+ if clean_note:
193
+ body["note"] = clean_note
194
+ if statement_index is not None:
195
+ body["statement_index"] = statement_index
196
+ return self.request("POST", f"/api/claims/{target}/reports", body, auth=True)
197
+ raise ValueError(f"report target must be a lookup id (lk_...) or claim id (AN-...): {target!r}")
198
+
199
+ def stack(self, dependencies: Optional[Dict[str, str]] = None, dev_dependencies: Optional[Dict[str, str]] = None,
200
+ engines: Optional[Dict[str, str]] = None, packages: Optional[List[str]] = None) -> Dict[str, Any]:
201
+ """package.json fields or ["name@version", ...] -> applicable deadlines, advisories and breaking changes."""
202
+ body: Dict[str, Any] = {}
203
+ if dependencies:
204
+ body["dependencies"] = dependencies
205
+ if dev_dependencies:
206
+ body["devDependencies"] = dev_dependencies
207
+ if engines:
208
+ body["engines"] = engines
209
+ if packages:
210
+ body["packages"] = packages
211
+ return self.request("POST", "/api/stack", body)
212
+
213
+ def services(self) -> Dict[str, Any]:
214
+ return self.request("GET", "/api/services")
File without changes
@@ -0,0 +1,219 @@
1
+ """Opt-in error-to-lookup helpers: decorator, context manager, explain(). Breakwire never masks your error."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import concurrent.futures
6
+ import functools
7
+ import inspect
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Callable, Dict, List, Optional
10
+
11
+ from .client import Breakwire
12
+
13
+ SKIP = object() # return this from retry() to skip retrying (the original exception is re-raised, nothing reported)
14
+ _pool = concurrent.futures.ThreadPoolExecutor(max_workers=4, thread_name_prefix="breakwire")
15
+
16
+
17
+ @dataclass
18
+ class BreakwireInfo:
19
+ """Attached to an exception as ``exc.breakwire``."""
20
+ lookup_id: Optional[str] = None
21
+ explained: bool = False # True only when a vendor-documented error signature matched
22
+ id: Optional[str] = None
23
+ title: Optional[str] = None
24
+ fix: Optional[str] = None
25
+ confidence: Optional[str] = None
26
+ url: Optional[str] = None
27
+ deadline: Optional[str] = None
28
+ matches: List[Dict[str, Any]] = field(default_factory=list)
29
+ coverage: Optional[Dict[str, Any]] = None
30
+ error: Optional[str] = None # set when Breakwire itself failed
31
+
32
+ @classmethod
33
+ def from_result(cls, r: Optional[Dict[str, Any]], error: Optional[str] = None) -> "BreakwireInfo":
34
+ r = r or {}
35
+ m = (r.get("matches") or [{}])[0] if r.get("matches") else {}
36
+ return cls(lookup_id=r.get("lookup_id"), explained=bool(r.get("explained")), id=m.get("cid"), title=m.get("title"),
37
+ fix=m.get("fix"), confidence=m.get("confidence"), url=m.get("url"), deadline=m.get("deadline"),
38
+ matches=r.get("matches") or [], coverage=r.get("coverage"), error=error)
39
+
40
+ def note(self) -> Optional[str]:
41
+ if not self.id:
42
+ return None
43
+ return f"Breakwire {self.id} ({self.confidence}): {self.title}\n fix: {self.fix}\n {self.url}"
44
+
45
+
46
+ _LOOKUP_KEYS = ("service", "sdk", "version", "model", "http_status", "context", "cross_service", "log_miss")
47
+
48
+
49
+ def explain(err: Any, client: Optional[Breakwire] = None, timeout: float = 2.5, **lookup: Any) -> BreakwireInfo:
50
+ """Look up an error. Never raises; returns at most ``timeout`` seconds later."""
51
+ try:
52
+ c = client or Breakwire(timeout=timeout)
53
+ fut = _pool.submit(c.lookup, err, **{k: v for k, v in lookup.items() if k in _LOOKUP_KEYS})
54
+ return BreakwireInfo.from_result(fut.result(timeout=timeout))
55
+ except concurrent.futures.TimeoutError:
56
+ return BreakwireInfo(error=f"breakwire lookup timed out after {timeout} s")
57
+ except Exception as e: # noqa: BLE001 - Breakwire must never raise into the caller
58
+ return BreakwireInfo(error=str(e) or type(e).__name__)
59
+
60
+
61
+ def _attach(exc: BaseException, info: BreakwireInfo, add_note: bool) -> None:
62
+ try:
63
+ exc.breakwire = info # type: ignore[attr-defined]
64
+ except Exception:
65
+ pass
66
+ n = info.note() if add_note else None
67
+ if n and hasattr(exc, "add_note"):
68
+ try:
69
+ exc.add_note(n) # shows in the traceback (Python 3.11+)
70
+ except Exception:
71
+ pass
72
+
73
+
74
+ def _safe_report(client: Breakwire, info: BreakwireInfo, outcome: str, env: Optional[Dict[str, str]], retry_err: Any = None) -> None:
75
+ try:
76
+ note = f"retry with suggested fix still failed: {client.redact(retry_err)[:300]}" if retry_err is not None else None
77
+ client.report(info.lookup_id or "", outcome, env or {}, cid=info.id, note=note)
78
+ except Exception:
79
+ pass
80
+
81
+
82
+ class with_breakwire:
83
+ """Decorator and context manager.
84
+
85
+ @with_breakwire(service="anthropic-api")
86
+ def call(): ...
87
+
88
+ with with_breakwire(service="openai-api"):
89
+ client.responses.create(...)
90
+
91
+ On an exception: looks it up, sets ``exc.breakwire`` (and a traceback note), then re-raises the ORIGINAL exception.
92
+ Options: should_lookup(exc) -> bool; on_match(info, exc); retry(info, exc, *args, **kwargs) -> result or SKIP (decorator only);
93
+ auto_report=True (explicit opt-in) reports the retry's outcome on the lookup; report_env={...}.
94
+ """
95
+
96
+ def __init__(self, func: Optional[Callable] = None, *, client: Optional[Breakwire] = None, timeout: float = 2.5,
97
+ should_lookup: Optional[Callable[[BaseException], bool]] = None,
98
+ on_match: Optional[Callable[[BreakwireInfo, BaseException], None]] = None,
99
+ retry: Optional[Callable[..., Any]] = None, auto_report: bool = False,
100
+ report_env: Optional[Dict[str, str]] = None, add_note: bool = True,
101
+ base_url: Optional[str] = None, **lookup: Any):
102
+ unknown = set(lookup) - set(_LOOKUP_KEYS)
103
+ if unknown:
104
+ raise TypeError(f"unknown with_breakwire options: {sorted(unknown)}")
105
+ self._client = client
106
+ self._base_url = base_url
107
+ self.timeout = timeout
108
+ self.should_lookup = should_lookup
109
+ self.on_match = on_match
110
+ self.retry = retry
111
+ self.auto_report = auto_report
112
+ self.report_env = report_env
113
+ self.add_note = add_note
114
+ self.lookup = lookup
115
+ self._func = func
116
+ if func is not None:
117
+ functools.update_wrapper(self, func)
118
+
119
+ @property
120
+ def client(self) -> Breakwire:
121
+ if self._client is None:
122
+ self._client = Breakwire(base_url=self._base_url, timeout=self.timeout)
123
+ return self._client
124
+
125
+ def _handle(self, exc: BaseException) -> Optional[BreakwireInfo]:
126
+ if not isinstance(exc, Exception): # KeyboardInterrupt, SystemExit, CancelledError: leave alone
127
+ return None
128
+ try:
129
+ if self.should_lookup and not self.should_lookup(exc):
130
+ return None
131
+ except Exception:
132
+ return None
133
+ try:
134
+ client = self.client
135
+ except Exception:
136
+ return None
137
+ info = explain(exc, client=client, timeout=self.timeout, **self.lookup)
138
+ _attach(exc, info, self.add_note)
139
+ if self.on_match:
140
+ try:
141
+ self.on_match(info, exc)
142
+ except Exception:
143
+ pass
144
+ return info
145
+
146
+ # context manager
147
+ def __enter__(self) -> "with_breakwire":
148
+ return self
149
+
150
+ def __exit__(self, et, exc, tb) -> bool:
151
+ if exc is not None:
152
+ self._handle(exc)
153
+ return False # never swallow
154
+
155
+ async def __aenter__(self) -> "with_breakwire":
156
+ return self
157
+
158
+ async def __aexit__(self, et, exc, tb) -> bool:
159
+ if exc is not None:
160
+ await asyncio.get_running_loop().run_in_executor(None, self._handle, exc)
161
+ return False
162
+
163
+ # decorator
164
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
165
+ if self._func is None: # used as @with_breakwire(...)
166
+ fn = args[0]
167
+ clone = with_breakwire(fn, client=self._client, timeout=self.timeout, should_lookup=self.should_lookup,
168
+ on_match=self.on_match, retry=self.retry, auto_report=self.auto_report,
169
+ report_env=self.report_env, add_note=self.add_note, base_url=self._base_url, **self.lookup)
170
+ if inspect.iscoroutinefunction(fn):
171
+ @functools.wraps(fn)
172
+ async def aw(*a: Any, **k: Any) -> Any:
173
+ return await clone._acall(*a, **k)
174
+ return aw
175
+ return clone
176
+ if inspect.iscoroutinefunction(self._func):
177
+ return self._acall(*args, **kwargs)
178
+ return self._call(*args, **kwargs)
179
+
180
+ def _call(self, *args: Any, **kwargs: Any) -> Any:
181
+ try:
182
+ return self._func(*args, **kwargs) # type: ignore[misc]
183
+ except Exception as exc:
184
+ info = self._handle(exc)
185
+ if self.retry and info and info.lookup_id and info.id:
186
+ try:
187
+ value = self.retry(info, exc, *args, **kwargs)
188
+ except Exception as retry_exc:
189
+ if self.auto_report:
190
+ _safe_report(self.client, info, "not_fixed", self.report_env, retry_exc)
191
+ raise exc
192
+ if value is SKIP:
193
+ raise
194
+ if self.auto_report:
195
+ _safe_report(self.client, info, "fixed", self.report_env)
196
+ return value
197
+ raise
198
+
199
+ async def _acall(self, *args: Any, **kwargs: Any) -> Any:
200
+ try:
201
+ return await self._func(*args, **kwargs) # type: ignore[misc]
202
+ except Exception as exc:
203
+ loop = asyncio.get_running_loop()
204
+ info = await loop.run_in_executor(None, self._handle, exc)
205
+ if self.retry and info and info.lookup_id and info.id:
206
+ try:
207
+ value = self.retry(info, exc, *args, **kwargs)
208
+ if inspect.isawaitable(value):
209
+ value = await value
210
+ except Exception as retry_exc:
211
+ if self.auto_report:
212
+ await loop.run_in_executor(None, _safe_report, self.client, info, "not_fixed", self.report_env, retry_exc)
213
+ raise exc
214
+ if value is SKIP:
215
+ raise
216
+ if self.auto_report:
217
+ await loop.run_in_executor(None, _safe_report, self.client, info, "fixed", self.report_env)
218
+ return value
219
+ raise
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: breakwire
3
+ Version: 0.1.0
4
+ Summary: Look up an API/SDK error and get the source-linked breaking change, deprecation or advisory that explains it, with the fix. Stdlib only.
5
+ Author: Breakwire
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://breakwire.org
8
+ Project-URL: Documentation, https://breakwire.org/llms.txt
9
+ Project-URL: Source, https://github.com/breakwire-org/breakwire/tree/main/packages/python
10
+ Project-URL: Issues, https://github.com/breakwire-org/breakwire/issues
11
+ Keywords: breaking-changes,deprecations,errors,ai-agents,llm,mcp,anthropic,openai,gemini
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # breakwire (Python)
23
+
24
+ Look up an API, SDK or platform error and get the source-linked breaking change, deprecation or advisory that explains
25
+ it, with the fix. Client for [Breakwire](https://breakwire.org), a registry of changes that break AI agents.
26
+
27
+ - Standard library only. Python 3.9+.
28
+ - Sends an explicit `User-Agent` (Cloudflare's edge rejects Python-urllib's default with 403).
29
+ - Opt-in decorator / context manager: never raises because of Breakwire, never replaces your exception.
30
+ - Secrets are stripped in your process before anything is sent. Outcome reporting is explicit opt-in.
31
+
32
+ ```bash
33
+ pip install breakwire
34
+ ```
35
+
36
+ ## Look up an error
37
+
38
+ ```python
39
+ from breakwire import Breakwire
40
+
41
+ bw = Breakwire() # base_url: argument, else $BREAKWIRE_BASE_URL, else https://breakwire.org
42
+ r = bw.lookup(exc, service="anthropic-api", sdk="anthropic-python", version="2.3.0")
43
+ if r["explained"]:
44
+ m = r["matches"][0]
45
+ print(m["cid"], m["fix"], m["url"])
46
+ ```
47
+
48
+ `explained` is `True` only when a vendor-documented error string matched (`confidence == "signature"`); `related`
49
+ matches are leads. Uncovered services return no matches plus a `coverage` note. `log_miss=False` keeps an unmatched error
50
+ out of the anonymous [/unexplained](https://breakwire.org/unexplained) clusters.
51
+
52
+ ## Decorator and context manager
53
+
54
+ ```python
55
+ from breakwire import with_breakwire
56
+
57
+ @with_breakwire(service="anthropic-api")
58
+ def ask(**params):
59
+ return client.messages.create(**params)
60
+
61
+ try:
62
+ ask(model=..., messages=...)
63
+ except anthropic.APIStatusError as e:
64
+ print(e.breakwire.id, e.breakwire.confidence, e.breakwire.fix) # also added as a traceback note (3.11+)
65
+ raise
66
+
67
+ with with_breakwire(service="openai-api"): # works with `async with` too
68
+ client.responses.create(...)
69
+ ```
70
+
71
+ Works on sync and `async def` functions. `timeout` (default 2.5 s) bounds the extra wait; Breakwire failures set
72
+ `e.breakwire.error` and your exception propagates unchanged. `KeyboardInterrupt`/`SystemExit`/`CancelledError` are never
73
+ looked up. Options: `should_lookup(exc)`, `on_match(info, exc)`, `add_note=False`.
74
+
75
+ ### Retry with the fix, and (opt-in) report whether it worked
76
+
77
+ ```python
78
+ def retry(info, exc, **params):
79
+ if info.id == "AN-2026-0025":
80
+ return client.messages.create(**{**params, "tool_choice": {"type": "auto"}})
81
+ return SKIP # from breakwire import SKIP
82
+
83
+ @with_breakwire(service="anthropic-api", retry=retry, auto_report=True, report_env={"sdk": "anthropic-python"})
84
+ def ask(**params): ...
85
+ ```
86
+
87
+ With `auto_report=True`, and only when `retry` ran, the lookup is closed as `fixed` (retry returned) or `not_fixed` (retry
88
+ raised). Nothing is reported otherwise.
89
+
90
+ ## Other calls
91
+
92
+ ```python
93
+ bw.deadlines("openai-api") # upcoming act-by dates, soonest first
94
+ bw.claim("AN-2026-0004") # full record; claim(id, version=2) for an immutable snapshot
95
+ bw.stack(packages=["anthropic==2.3.0", "openai>=1"])
96
+ bw.services()
97
+ bw.report("lk_...", "fixed", {"sdk": "openai"}) # close a lookup (anonymous)
98
+ bw.report("AN-2026-0025", "confirmed", env) # claim report; needs api_key / $BREAKWIRE_API_KEY
99
+ ```
100
+
101
+ Only report outcomes you actually observed.
102
+
103
+ ## Secret stripping
104
+
105
+ `redact_secrets()` covers provider keys (Anthropic, OpenAI, Stripe, GitHub, Slack, Google, AWS, npm, HF), JWTs, bearer
106
+ tokens, `key=value` secrets, PEM blocks, URL credentials, query strings, emails, IPv4, home-directory user names and long
107
+ opaque blobs. Add your own: `Breakwire(secrets=[my_token, re.compile(r"acme_[a-z0-9]+")])`.
108
+
109
+ ## Honest status
110
+
111
+ Each claim links a primary source; community verification has just started and most claims have no outcome reports yet.
112
+ Claims are agent-written data; prefer the linked source.
113
+
114
+ MIT. Docs: https://breakwire.org/llms.txt
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/breakwire/__init__.py
5
+ src/breakwire/_redact.py
6
+ src/breakwire/client.py
7
+ src/breakwire/py.typed
8
+ src/breakwire/wrap.py
9
+ src/breakwire.egg-info/PKG-INFO
10
+ src/breakwire.egg-info/SOURCES.txt
11
+ src/breakwire.egg-info/dependency_links.txt
12
+ src/breakwire.egg-info/top_level.txt
13
+ tests/test_unit.py
@@ -0,0 +1 @@
1
+ breakwire
@@ -0,0 +1,304 @@
1
+ """Offline unit tests: a local stdlib HTTP server stands in for the API. Run: python -m unittest discover -s tests"""
2
+ import asyncio
3
+ import json
4
+ import os
5
+ import pathlib
6
+ import sys
7
+ import threading
8
+ import time
9
+ import unittest
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+
12
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src"))
13
+ from breakwire import SKIP, Breakwire, BreakwireError, DEFAULT_BASE_URL, error_text, explain, redact_secrets, with_breakwire # noqa: E402
14
+
15
+ CASES = json.loads((pathlib.Path(__file__).resolve().parents[3] / "tests" / "redaction-cases.json").read_text())
16
+ LOOKUP = {"lookup_id": "lk_abc123", "explained": True, "coverage": None, "matches": [
17
+ {"cid": "AN-2026-0025", "title": "T", "confidence": "signature", "fix": "Use tool_choice auto", "url": "https://breakwire.org/claim/AN-2026-0025", "deadline": None}]}
18
+
19
+
20
+ class Server:
21
+ def __init__(self):
22
+ self.calls, self.mode = [], "ok"
23
+ outer = self
24
+
25
+ class H(BaseHTTPRequestHandler):
26
+ def log_message(self, *a):
27
+ pass
28
+
29
+ def _go(self):
30
+ n = int(self.headers.get("content-length") or 0)
31
+ body = json.loads(self.rfile.read(n)) if n else None
32
+ outer.calls.append({"method": self.command, "path": self.path, "headers": dict(self.headers), "body": body})
33
+ if outer.mode == "slow":
34
+ time.sleep(1.5)
35
+ if outer.mode == "500":
36
+ return self._send(500, {"error": "boom"})
37
+ if outer.mode == "nomatch" and self.path == "/api/lookup":
38
+ return self._send(200, {**LOOKUP, "explained": False, "matches": []})
39
+ if self.path.startswith("/old/"):
40
+ self.send_response(301)
41
+ self.send_header("location", self.path[4:])
42
+ self.end_headers()
43
+ return
44
+ if self.path.startswith("/api/deadlines"):
45
+ return self._send(200, {"deadlines": [{"cid": "AN-2026-0001"}]})
46
+ if self.path == "/api/lookup":
47
+ return self._send(200, LOOKUP)
48
+ return self._send(200, {"ok": True})
49
+
50
+ do_GET = do_POST = _go
51
+
52
+ def _send(self, code, obj):
53
+ data = json.dumps(obj).encode()
54
+ self.send_response(code)
55
+ self.send_header("content-type", "application/json")
56
+ self.send_header("content-length", str(len(data)))
57
+ self.end_headers()
58
+ self.wfile.write(data)
59
+
60
+ self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), H)
61
+ self.url = f"http://127.0.0.1:{self.httpd.server_port}"
62
+ threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
63
+
64
+
65
+ S = Server()
66
+
67
+
68
+ def bw(**kw):
69
+ return Breakwire(base_url=S.url, **kw)
70
+
71
+
72
+ class T(unittest.TestCase):
73
+ def setUp(self):
74
+ S.calls.clear()
75
+ S.mode = "ok"
76
+
77
+ def test_redaction_cases(self):
78
+ for c in CASES:
79
+ out = redact_secrets(c["in"])
80
+ for a in c["absent"]:
81
+ self.assertNotIn(a, out, f"leaked in {out}")
82
+ for p in c["present"]:
83
+ self.assertIn(p, out, f"lost in {out}")
84
+
85
+ def test_base_url(self):
86
+ os.environ.pop("BREAKWIRE_BASE_URL", None)
87
+ self.assertEqual(Breakwire().base_url, "https://breakwire.org")
88
+ self.assertEqual(DEFAULT_BASE_URL, "https://breakwire.org")
89
+ os.environ["BREAKWIRE_BASE_URL"] = "https://e.test/"
90
+ self.assertEqual(Breakwire().base_url, "https://e.test")
91
+ del os.environ["BREAKWIRE_BASE_URL"]
92
+
93
+ def test_lookup_redacts_and_sets_user_agent(self):
94
+ e = Exception("invalid x-api-key sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWx")
95
+ e.status_code = 401
96
+ r = bw(user_agent="my-agent/1").lookup(e, service="anthropic-api", sdk="anthropic-python", version="0.60.0", log_miss=False)
97
+ self.assertEqual(r["lookup_id"], "lk_abc123")
98
+ c = S.calls[0]
99
+ self.assertEqual((c["method"], c["path"]), ("POST", "/api/lookup"))
100
+ self.assertTrue(c["headers"]["User-Agent"].startswith("breakwire-python/"))
101
+ self.assertNotIn("Python-urllib", c["headers"]["User-Agent"])
102
+ self.assertNotIn("AbCdEfGh", json.dumps(c["body"]))
103
+ self.assertEqual({k: v for k, v in c["body"].items() if k != "error"},
104
+ {"service": "anthropic-api", "sdk": "anthropic-python", "sdk_version": "0.60.0", "http_status": 401, "log_miss": False})
105
+ self.assertNotIn("Authorization", c["headers"])
106
+
107
+ def test_post_resent_across_301(self):
108
+ b = Breakwire(base_url=S.url + "/old")
109
+ self.assertEqual(b.lookup("some error text")["lookup_id"], "lk_abc123")
110
+ self.assertEqual([(c["method"], c["path"]) for c in S.calls], [("POST", "/old/api/lookup"), ("POST", "/api/lookup")])
111
+ self.assertEqual(S.calls[1]["body"]["error"], "some error text")
112
+
113
+ def test_routes(self):
114
+ b = bw()
115
+ self.assertEqual(b.deadlines("stripe-api", limit=5)[0]["cid"], "AN-2026-0001")
116
+ b.deadlines()
117
+ b.claim("AN-2026-0001")
118
+ b.claim("AN-2026-0001", version=2)
119
+ b.stack(packages=["next@15"])
120
+ b.services()
121
+ self.assertEqual([f"{c['method']} {c['path']}" for c in S.calls], [
122
+ "GET /api/deadlines?service=stripe-api&limit=5", "GET /api/deadlines", "GET /api/claims/AN-2026-0001",
123
+ "GET /api/claims/AN-2026-0001/versions/2", "POST /api/stack", "GET /api/services"])
124
+ with self.assertRaises(ValueError):
125
+ b.claim("../x")
126
+
127
+ def test_report(self):
128
+ b = bw()
129
+ b.report("lk_abc123", "fixed", {"sdk": "openai", "runtime": "py key=sk-proj-abcdefghijklmnop12"}, cid="AN-2026-0025")
130
+ c = S.calls[0]
131
+ self.assertEqual((c["path"], c["body"]["outcome"], c["body"]["cid"]), ("/api/lookups/lk_abc123/outcome", "fixed", "AN-2026-0025"))
132
+ self.assertNotIn("abcdefghijklmnop12", json.dumps(c["body"]))
133
+ b.report("lk_abc123", "disputed")
134
+ self.assertEqual(S.calls[1]["body"]["outcome"], "not_fixed")
135
+ with self.assertRaises(ValueError):
136
+ b.report("AN-2026-0025", "confirmed")
137
+ k = bw(api_key="k_test")
138
+ with self.assertRaises(ValueError):
139
+ k.report("AN-2026-0025", "not_fixed")
140
+ k.report("AN-2026-0025", "fixed", statement_index=0)
141
+ last = S.calls[-1]
142
+ self.assertEqual((last["path"], last["body"]["outcome"], last["body"]["statement_index"], last["headers"]["Authorization"]),
143
+ ("/api/claims/AN-2026-0025/reports", "confirmed", 0, "Bearer k_test"))
144
+ for bad in (("bogus", "fixed"), ("lk_x", "maybe")):
145
+ with self.assertRaises(ValueError):
146
+ b.report(*bad)
147
+
148
+ def test_http_error(self):
149
+ S.mode = "500"
150
+ with self.assertRaises(BreakwireError) as cm:
151
+ bw().services()
152
+ self.assertEqual(cm.exception.status, 500)
153
+
154
+ def test_error_text(self):
155
+ try:
156
+ try:
157
+ raise KeyError("inner")
158
+ except KeyError as k:
159
+ e = RuntimeError("400 bad")
160
+ e.status_code = 400
161
+ e.body = {"error": {"type": "invalid_request_error", "message": "tool_choice not supported"}}
162
+ raise e from k
163
+ except RuntimeError as e:
164
+ t = error_text(e)
165
+ for s in ("RuntimeError: 400 bad", "invalid_request_error", "tool_choice not supported", "cause: KeyError"):
166
+ self.assertIn(s, t)
167
+ self.assertNotIn("Traceback", t)
168
+
169
+ def test_decorator_attaches_and_reraises(self):
170
+ orig = ValueError("tool_choice boom")
171
+
172
+ @with_breakwire(base_url=S.url, service="anthropic-api")
173
+ def f():
174
+ raise orig
175
+ with self.assertRaises(ValueError) as cm:
176
+ f()
177
+ self.assertIs(cm.exception, orig)
178
+ self.assertEqual((orig.breakwire.id, orig.breakwire.fix, orig.breakwire.confidence, orig.breakwire.lookup_id),
179
+ ("AN-2026-0025", "Use tool_choice auto", "signature", "lk_abc123"))
180
+ if sys.version_info >= (3, 11):
181
+ self.assertIn("Breakwire AN-2026-0025", "\n".join(orig.__notes__))
182
+
183
+ def test_bare_decorator_and_success_no_network(self):
184
+ @with_breakwire
185
+ def add(a, b):
186
+ return a + b
187
+ self.assertEqual(add(2, 3), 5)
188
+ self.assertEqual(S.calls, [])
189
+
190
+ def test_context_manager(self):
191
+ orig = RuntimeError("x")
192
+ with self.assertRaises(RuntimeError) as cm:
193
+ with with_breakwire(base_url=S.url, service="openai-api"):
194
+ raise orig
195
+ self.assertIs(cm.exception, orig)
196
+ self.assertEqual(orig.breakwire.id, "AN-2026-0025")
197
+ self.assertEqual(S.calls[0]["body"]["service"], "openai-api")
198
+
199
+ def test_breakwire_failure_never_masks(self):
200
+ S.mode = "500"
201
+ orig = RuntimeError("real")
202
+ with self.assertRaises(RuntimeError) as cm:
203
+ with with_breakwire(base_url=S.url):
204
+ raise orig
205
+ self.assertIs(cm.exception, orig)
206
+ self.assertIsNone(orig.breakwire.id)
207
+ self.assertIn("500", orig.breakwire.error)
208
+ # unreachable host
209
+ orig2 = RuntimeError("real2")
210
+ with self.assertRaises(RuntimeError):
211
+ with with_breakwire(base_url="http://127.0.0.1:9", timeout=1):
212
+ raise orig2
213
+ self.assertIsNotNone(orig2.breakwire.error)
214
+
215
+ def test_timeout(self):
216
+ S.mode = "slow"
217
+ t0 = time.time()
218
+ info = explain(Exception("x"), client=bw(timeout=5), timeout=0.3)
219
+ self.assertLess(time.time() - t0, 1.0)
220
+ self.assertIn("timed out", info.error)
221
+
222
+ def test_should_lookup_and_base_exceptions(self):
223
+ with self.assertRaises(KeyError):
224
+ with with_breakwire(base_url=S.url, should_lookup=lambda e: not isinstance(e, KeyError)):
225
+ raise KeyError("mine")
226
+ with self.assertRaises(KeyboardInterrupt):
227
+ with with_breakwire(base_url=S.url):
228
+ raise KeyboardInterrupt()
229
+ self.assertEqual(S.calls, [])
230
+
231
+ def test_auto_report_off_by_default(self):
232
+ @with_breakwire(base_url=S.url, retry=lambda info, exc: "retried")
233
+ def f():
234
+ raise RuntimeError("x")
235
+ self.assertEqual(f(), "retried")
236
+ self.assertEqual([c["path"] for c in S.calls], ["/api/lookup"])
237
+
238
+ def test_auto_report_opt_in(self):
239
+ @with_breakwire(base_url=S.url, auto_report=True, report_env={"sdk": "t"}, retry=lambda info, exc: "ok")
240
+ def good():
241
+ raise RuntimeError("x")
242
+ self.assertEqual(good(), "ok")
243
+ self.assertEqual((S.calls[1]["path"], S.calls[1]["body"]["outcome"], S.calls[1]["body"]["cid"]), ("/api/lookups/lk_abc123/outcome", "fixed", "AN-2026-0025"))
244
+
245
+ def still_broken(info, exc):
246
+ raise RuntimeError("still")
247
+ orig = RuntimeError("y")
248
+
249
+ @with_breakwire(base_url=S.url, auto_report=True, retry=still_broken)
250
+ def bad():
251
+ raise orig
252
+ with self.assertRaises(RuntimeError) as cm:
253
+ bad()
254
+ self.assertIs(cm.exception, orig)
255
+ self.assertEqual(S.calls[-1]["body"]["outcome"], "not_fixed")
256
+
257
+ def test_skip_and_no_match_never_report(self):
258
+ @with_breakwire(base_url=S.url, auto_report=True, retry=lambda info, exc: SKIP)
259
+ def f():
260
+ raise RuntimeError("x")
261
+ with self.assertRaises(RuntimeError):
262
+ f()
263
+ S.mode = "nomatch"
264
+ called = []
265
+
266
+ @with_breakwire(base_url=S.url, auto_report=True, retry=lambda info, exc: called.append(1))
267
+ def g():
268
+ raise RuntimeError("x")
269
+ with self.assertRaises(RuntimeError):
270
+ g()
271
+ self.assertEqual(called, [])
272
+ self.assertEqual([c["path"] for c in S.calls], ["/api/lookup", "/api/lookup"])
273
+
274
+ def test_async_decorator_and_context(self):
275
+ orig = RuntimeError("async boom")
276
+
277
+ @with_breakwire(base_url=S.url)
278
+ async def f():
279
+ raise orig
280
+
281
+ @with_breakwire
282
+ async def ok():
283
+ return 7
284
+
285
+ async def main():
286
+ self.assertEqual(await ok(), 7)
287
+ with self.assertRaises(RuntimeError):
288
+ await f()
289
+ o2 = RuntimeError("ctx")
290
+ with self.assertRaises(RuntimeError):
291
+ async with with_breakwire(base_url=S.url):
292
+ raise o2
293
+ return o2
294
+ o2 = asyncio.run(main())
295
+ self.assertEqual(orig.breakwire.id, "AN-2026-0025")
296
+ self.assertEqual(o2.breakwire.id, "AN-2026-0025")
297
+
298
+ def test_unknown_option_rejected(self):
299
+ with self.assertRaises(TypeError):
300
+ with_breakwire(servce="typo")
301
+
302
+
303
+ if __name__ == "__main__":
304
+ unittest.main()