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.
- breakwire-0.1.0/LICENSE +21 -0
- breakwire-0.1.0/PKG-INFO +114 -0
- breakwire-0.1.0/README.md +93 -0
- breakwire-0.1.0/pyproject.toml +34 -0
- breakwire-0.1.0/setup.cfg +4 -0
- breakwire-0.1.0/src/breakwire/__init__.py +12 -0
- breakwire-0.1.0/src/breakwire/_redact.py +45 -0
- breakwire-0.1.0/src/breakwire/client.py +214 -0
- breakwire-0.1.0/src/breakwire/py.typed +0 -0
- breakwire-0.1.0/src/breakwire/wrap.py +219 -0
- breakwire-0.1.0/src/breakwire.egg-info/PKG-INFO +114 -0
- breakwire-0.1.0/src/breakwire.egg-info/SOURCES.txt +13 -0
- breakwire-0.1.0/src/breakwire.egg-info/dependency_links.txt +1 -0
- breakwire-0.1.0/src/breakwire.egg-info/top_level.txt +1 -0
- breakwire-0.1.0/tests/test_unit.py +304 -0
breakwire-0.1.0/LICENSE
ADDED
|
@@ -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.
|
breakwire-0.1.0/PKG-INFO
ADDED
|
@@ -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,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
|
+
|
|
@@ -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()
|