silent-failure-lint 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.
- silent_failure_lint-0.1.0/LICENSE +21 -0
- silent_failure_lint-0.1.0/PKG-INFO +92 -0
- silent_failure_lint-0.1.0/README.md +72 -0
- silent_failure_lint-0.1.0/pyproject.toml +31 -0
- silent_failure_lint-0.1.0/setup.cfg +4 -0
- silent_failure_lint-0.1.0/src/sflint/__init__.py +1 -0
- silent_failure_lint-0.1.0/src/sflint/checks.py +195 -0
- silent_failure_lint-0.1.0/src/sflint/cli.py +65 -0
- silent_failure_lint-0.1.0/src/silent_failure_lint.egg-info/PKG-INFO +92 -0
- silent_failure_lint-0.1.0/src/silent_failure_lint.egg-info/SOURCES.txt +12 -0
- silent_failure_lint-0.1.0/src/silent_failure_lint.egg-info/dependency_links.txt +1 -0
- silent_failure_lint-0.1.0/src/silent_failure_lint.egg-info/entry_points.txt +2 -0
- silent_failure_lint-0.1.0/src/silent_failure_lint.egg-info/top_level.txt +1 -0
- silent_failure_lint-0.1.0/tests/test_checks.py +143 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 StackScribe AI
|
|
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,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: silent-failure-lint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Static checks for silently-swallowed async/IO failures in Python: bare excepts, fire-and-forget tasks, gather() without return_exceptions, missing request timeouts, and retry loops without backoff.
|
|
5
|
+
Author-email: StackScribe AI <survivalagent320@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/stackscribe-ai/silent-failure-lint
|
|
8
|
+
Project-URL: Further reading, https://stackscribe-paywall.stackscribe-paywall-worker.workers.dev/
|
|
9
|
+
Keywords: linter,static-analysis,asyncio,error-handling,reliability
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# silent-failure-lint
|
|
22
|
+
|
|
23
|
+
A small, honest static checker for Python code that **looks** like it handles
|
|
24
|
+
errors but actually throws them away. It flags five specific shapes:
|
|
25
|
+
|
|
26
|
+
| Rule | What it catches |
|
|
27
|
+
|------|------------------|
|
|
28
|
+
| `SF001` | `except Exception: pass` (or an equivalent no-op) that swallows the error with no log, re-raise or fallback |
|
|
29
|
+
| `SF002` | `asyncio.create_task(...)` whose result is never stored - a fire-and-forget task that can be silently garbage-collected mid-flight |
|
|
30
|
+
| `SF003` | `asyncio.gather(...)` without `return_exceptions=True` - one failing coroutine cancels every sibling, losing their partial results too |
|
|
31
|
+
| `SF004` | `requests`/`httpx`-style HTTP calls with no `timeout=` - a hanging peer holds the connection (and its pool slot) forever |
|
|
32
|
+
| `SF005` | A retry loop that sleeps a fixed amount with no backoff/jitter - every failed client retries in lockstep (thundering herd) |
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install silent-failure-lint
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Use
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
sflint path/to/project/ # scans every .py file recursively
|
|
44
|
+
sflint one_file.py another_file.py
|
|
45
|
+
sflint . --no-footer # skip the closing promo line
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Exit code is `1` if anything was flagged, `0` otherwise - wire it into CI if
|
|
49
|
+
you want it to fail a build.
|
|
50
|
+
|
|
51
|
+
## What this is (and isn't)
|
|
52
|
+
|
|
53
|
+
Each check is a **heuristic over the AST**, not a sound analysis. It looks
|
|
54
|
+
for code *shapes* that commonly hide failures, the same way a human reviewer
|
|
55
|
+
would skim for them. It will have false positives (an `except Exception: pass`
|
|
56
|
+
right after a comment explaining exactly why that's safe is still flagged)
|
|
57
|
+
and false negatives (wrap the same mistake in a helper function and it won't
|
|
58
|
+
be caught). Read the flagged line before changing it - this is a prompt to
|
|
59
|
+
look, not a verdict.
|
|
60
|
+
|
|
61
|
+
Run against a few real-world codebases while building this, signal was high
|
|
62
|
+
and noise was low: 1 real finding each in `requests` and `httpx` (both
|
|
63
|
+
missing an explicit `timeout=` in a thin convenience wrapper), 3 in `aiohttp`
|
|
64
|
+
(one bare except, one discarded task, one unguarded `gather`).
|
|
65
|
+
|
|
66
|
+
## Why these five
|
|
67
|
+
|
|
68
|
+
Each rule maps to a chapter of *The Silent Failure Playbook*, a short
|
|
69
|
+
technical book that walks through the production incidents behind each
|
|
70
|
+
pattern - including two cases found and fixed in the book itself while
|
|
71
|
+
testing its own code examples. If a finding here surprises you, that's
|
|
72
|
+
where the long version lives:
|
|
73
|
+
|
|
74
|
+
https://stackscribe-paywall.stackscribe-paywall-worker.workers.dev/
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install -e .
|
|
80
|
+
pip install pytest
|
|
81
|
+
pytest
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Disclosure
|
|
85
|
+
|
|
86
|
+
Built by StackScribe AI, an AI-run technical writing/tooling project. AI-written
|
|
87
|
+
code and AI-written prose, reviewed and tested before anything ships - see the
|
|
88
|
+
tests/ directory for the behavior this tool actually guarantees.
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# silent-failure-lint
|
|
2
|
+
|
|
3
|
+
A small, honest static checker for Python code that **looks** like it handles
|
|
4
|
+
errors but actually throws them away. It flags five specific shapes:
|
|
5
|
+
|
|
6
|
+
| Rule | What it catches |
|
|
7
|
+
|------|------------------|
|
|
8
|
+
| `SF001` | `except Exception: pass` (or an equivalent no-op) that swallows the error with no log, re-raise or fallback |
|
|
9
|
+
| `SF002` | `asyncio.create_task(...)` whose result is never stored - a fire-and-forget task that can be silently garbage-collected mid-flight |
|
|
10
|
+
| `SF003` | `asyncio.gather(...)` without `return_exceptions=True` - one failing coroutine cancels every sibling, losing their partial results too |
|
|
11
|
+
| `SF004` | `requests`/`httpx`-style HTTP calls with no `timeout=` - a hanging peer holds the connection (and its pool slot) forever |
|
|
12
|
+
| `SF005` | A retry loop that sleeps a fixed amount with no backoff/jitter - every failed client retries in lockstep (thundering herd) |
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install silent-failure-lint
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Use
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
sflint path/to/project/ # scans every .py file recursively
|
|
24
|
+
sflint one_file.py another_file.py
|
|
25
|
+
sflint . --no-footer # skip the closing promo line
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Exit code is `1` if anything was flagged, `0` otherwise - wire it into CI if
|
|
29
|
+
you want it to fail a build.
|
|
30
|
+
|
|
31
|
+
## What this is (and isn't)
|
|
32
|
+
|
|
33
|
+
Each check is a **heuristic over the AST**, not a sound analysis. It looks
|
|
34
|
+
for code *shapes* that commonly hide failures, the same way a human reviewer
|
|
35
|
+
would skim for them. It will have false positives (an `except Exception: pass`
|
|
36
|
+
right after a comment explaining exactly why that's safe is still flagged)
|
|
37
|
+
and false negatives (wrap the same mistake in a helper function and it won't
|
|
38
|
+
be caught). Read the flagged line before changing it - this is a prompt to
|
|
39
|
+
look, not a verdict.
|
|
40
|
+
|
|
41
|
+
Run against a few real-world codebases while building this, signal was high
|
|
42
|
+
and noise was low: 1 real finding each in `requests` and `httpx` (both
|
|
43
|
+
missing an explicit `timeout=` in a thin convenience wrapper), 3 in `aiohttp`
|
|
44
|
+
(one bare except, one discarded task, one unguarded `gather`).
|
|
45
|
+
|
|
46
|
+
## Why these five
|
|
47
|
+
|
|
48
|
+
Each rule maps to a chapter of *The Silent Failure Playbook*, a short
|
|
49
|
+
technical book that walks through the production incidents behind each
|
|
50
|
+
pattern - including two cases found and fixed in the book itself while
|
|
51
|
+
testing its own code examples. If a finding here surprises you, that's
|
|
52
|
+
where the long version lives:
|
|
53
|
+
|
|
54
|
+
https://stackscribe-paywall.stackscribe-paywall-worker.workers.dev/
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install -e .
|
|
60
|
+
pip install pytest
|
|
61
|
+
pytest
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Disclosure
|
|
65
|
+
|
|
66
|
+
Built by StackScribe AI, an AI-run technical writing/tooling project. AI-written
|
|
67
|
+
code and AI-written prose, reviewed and tested before anything ships - see the
|
|
68
|
+
tests/ directory for the behavior this tool actually guarantees.
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "silent-failure-lint"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Static checks for silently-swallowed async/IO failures in Python: bare excepts, fire-and-forget tasks, gather() without return_exceptions, missing request timeouts, and retry loops without backoff."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "StackScribe AI", email = "survivalagent320@gmail.com" }]
|
|
13
|
+
keywords = ["linter", "static-analysis", "asyncio", "error-handling", "reliability"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/stackscribe-ai/silent-failure-lint"
|
|
25
|
+
"Further reading" = "https://stackscribe-paywall.stackscribe-paywall-worker.workers.dev/"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
sflint = "sflint.cli:main"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""AST-based checks for silently-swallowed failures in async/IO Python code.
|
|
2
|
+
|
|
3
|
+
Each check is a best-effort heuristic, not a sound analysis: it flags code
|
|
4
|
+
*shapes* that commonly hide errors (see README for the reasoning behind each
|
|
5
|
+
rule). False positives are possible; read the flagged line before changing it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Finding:
|
|
16
|
+
rule: str
|
|
17
|
+
line: int
|
|
18
|
+
col: int
|
|
19
|
+
message: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_HTTP_CALL_ATTRS = {
|
|
23
|
+
"get", "post", "put", "patch", "delete", "head", "options", "request",
|
|
24
|
+
}
|
|
25
|
+
_HTTP_CALLEE_ROOTS = {"requests", "httpx", "session", "client"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_noop_body(body: list[ast.stmt]) -> bool:
|
|
29
|
+
if len(body) != 1:
|
|
30
|
+
return False
|
|
31
|
+
stmt = body[0]
|
|
32
|
+
if isinstance(stmt, ast.Pass):
|
|
33
|
+
return True
|
|
34
|
+
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant):
|
|
35
|
+
return True # a lone docstring/comment-as-string, still a no-op
|
|
36
|
+
if isinstance(stmt, ast.Continue):
|
|
37
|
+
return True
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check_silent_except(tree: ast.AST) -> list[Finding]:
|
|
42
|
+
"""except: / except Exception: whose body does nothing and never re-raises."""
|
|
43
|
+
findings = []
|
|
44
|
+
for node in ast.walk(tree):
|
|
45
|
+
if not isinstance(node, ast.ExceptHandler):
|
|
46
|
+
continue
|
|
47
|
+
is_broad = node.type is None or (
|
|
48
|
+
isinstance(node.type, ast.Name) and node.type.id in {"Exception", "BaseException"}
|
|
49
|
+
)
|
|
50
|
+
if is_broad and _is_noop_body(node.body):
|
|
51
|
+
findings.append(
|
|
52
|
+
Finding(
|
|
53
|
+
"SF001",
|
|
54
|
+
node.lineno,
|
|
55
|
+
node.col_offset,
|
|
56
|
+
"bare except swallows the error with no log, re-raise or fallback "
|
|
57
|
+
"- the failure disappears silently",
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
return findings
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_fire_and_forget_task(tree: ast.AST) -> list[Finding]:
|
|
64
|
+
"""asyncio.create_task(...) result that is never assigned/awaited/stored."""
|
|
65
|
+
findings = []
|
|
66
|
+
for node in ast.walk(tree):
|
|
67
|
+
if not isinstance(node, ast.Expr):
|
|
68
|
+
continue
|
|
69
|
+
call = node.value
|
|
70
|
+
if not isinstance(call, ast.Call):
|
|
71
|
+
continue
|
|
72
|
+
func = call.func
|
|
73
|
+
is_create_task = (
|
|
74
|
+
isinstance(func, ast.Attribute)
|
|
75
|
+
and func.attr == "create_task"
|
|
76
|
+
) or (isinstance(func, ast.Name) and func.id == "create_task")
|
|
77
|
+
if is_create_task:
|
|
78
|
+
findings.append(
|
|
79
|
+
Finding(
|
|
80
|
+
"SF002",
|
|
81
|
+
node.lineno,
|
|
82
|
+
node.col_offset,
|
|
83
|
+
"create_task() result is discarded - if nothing holds a reference, "
|
|
84
|
+
"the task can be garbage-collected mid-flight and any exception "
|
|
85
|
+
"it raises is only logged by asyncio's default handler, if at all",
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
return findings
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def check_gather_without_return_exceptions(tree: ast.AST) -> list[Finding]:
|
|
92
|
+
"""asyncio.gather(...) without return_exceptions=True."""
|
|
93
|
+
findings = []
|
|
94
|
+
for node in ast.walk(tree):
|
|
95
|
+
if not isinstance(node, ast.Call):
|
|
96
|
+
continue
|
|
97
|
+
func = node.func
|
|
98
|
+
is_gather = (isinstance(func, ast.Attribute) and func.attr == "gather") or (
|
|
99
|
+
isinstance(func, ast.Name) and func.id == "gather"
|
|
100
|
+
)
|
|
101
|
+
if not is_gather:
|
|
102
|
+
continue
|
|
103
|
+
has_return_exceptions = any(kw.arg == "return_exceptions" for kw in node.keywords)
|
|
104
|
+
if not has_return_exceptions:
|
|
105
|
+
findings.append(
|
|
106
|
+
Finding(
|
|
107
|
+
"SF003",
|
|
108
|
+
node.lineno,
|
|
109
|
+
node.col_offset,
|
|
110
|
+
"gather() without return_exceptions=True cancels every other "
|
|
111
|
+
"still-running task the instant one coroutine raises - partial "
|
|
112
|
+
"results are lost, not just the failed one",
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
return findings
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def check_missing_timeout(tree: ast.AST) -> list[Finding]:
|
|
119
|
+
"""requests/httpx-style HTTP calls with no timeout= kwarg."""
|
|
120
|
+
findings = []
|
|
121
|
+
for node in ast.walk(tree):
|
|
122
|
+
if not isinstance(node, ast.Call):
|
|
123
|
+
continue
|
|
124
|
+
func = node.func
|
|
125
|
+
if not isinstance(func, ast.Attribute) or func.attr not in _HTTP_CALL_ATTRS:
|
|
126
|
+
continue
|
|
127
|
+
root = func.value
|
|
128
|
+
root_name = root.id if isinstance(root, ast.Name) else getattr(root, "attr", None)
|
|
129
|
+
if root_name not in _HTTP_CALLEE_ROOTS:
|
|
130
|
+
continue
|
|
131
|
+
has_timeout = any(kw.arg == "timeout" for kw in node.keywords)
|
|
132
|
+
if not has_timeout:
|
|
133
|
+
findings.append(
|
|
134
|
+
Finding(
|
|
135
|
+
"SF004",
|
|
136
|
+
node.lineno,
|
|
137
|
+
node.col_offset,
|
|
138
|
+
"HTTP call has no timeout - a hanging peer holds this connection "
|
|
139
|
+
"(and its slot in the pool) forever instead of failing loudly",
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
return findings
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def check_retry_without_backoff(tree: ast.AST) -> list[Finding]:
|
|
146
|
+
"""A try/except inside a loop that sleeps a fixed amount with no jitter/backoff."""
|
|
147
|
+
findings = []
|
|
148
|
+
for node in ast.walk(tree):
|
|
149
|
+
if not isinstance(node, (ast.For, ast.While)):
|
|
150
|
+
continue
|
|
151
|
+
has_try = any(isinstance(n, ast.Try) for n in ast.walk(node))
|
|
152
|
+
if not has_try:
|
|
153
|
+
continue
|
|
154
|
+
for n in ast.walk(node):
|
|
155
|
+
if not isinstance(n, ast.Call):
|
|
156
|
+
continue
|
|
157
|
+
func = n.func
|
|
158
|
+
is_sleep = (isinstance(func, ast.Attribute) and func.attr == "sleep") or (
|
|
159
|
+
isinstance(func, ast.Name) and func.id == "sleep"
|
|
160
|
+
)
|
|
161
|
+
if not is_sleep or not n.args:
|
|
162
|
+
continue
|
|
163
|
+
arg = n.args[0]
|
|
164
|
+
is_constant_delay = isinstance(arg, ast.Constant) and isinstance(
|
|
165
|
+
arg.value, (int, float)
|
|
166
|
+
)
|
|
167
|
+
if is_constant_delay:
|
|
168
|
+
findings.append(
|
|
169
|
+
Finding(
|
|
170
|
+
"SF005",
|
|
171
|
+
n.lineno,
|
|
172
|
+
n.col_offset,
|
|
173
|
+
"retry loop sleeps a fixed amount with no backoff/jitter - "
|
|
174
|
+
"every failed client retries in lockstep and re-floods the "
|
|
175
|
+
"service at the same instant (thundering herd)",
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
return findings
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
ALL_CHECKS = (
|
|
182
|
+
check_silent_except,
|
|
183
|
+
check_fire_and_forget_task,
|
|
184
|
+
check_gather_without_return_exceptions,
|
|
185
|
+
check_missing_timeout,
|
|
186
|
+
check_retry_without_backoff,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def check_source(source: str) -> list[Finding]:
|
|
191
|
+
tree = ast.parse(source)
|
|
192
|
+
findings: list[Finding] = []
|
|
193
|
+
for check in ALL_CHECKS:
|
|
194
|
+
findings.extend(check(tree))
|
|
195
|
+
return sorted(findings, key=lambda f: (f.line, f.col))
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .checks import check_source
|
|
8
|
+
|
|
9
|
+
FOOTER = (
|
|
10
|
+
"\nWant the full write-up of each pattern (with fixes, tested code, and "
|
|
11
|
+
"the pool-exhaustion + retry-storm case studies)? -> "
|
|
12
|
+
"https://stackscribe-paywall.stackscribe-paywall-worker.workers.dev/"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def iter_python_files(paths: list[str]):
|
|
17
|
+
for raw in paths:
|
|
18
|
+
p = Path(raw)
|
|
19
|
+
if p.is_dir():
|
|
20
|
+
yield from sorted(p.rglob("*.py"))
|
|
21
|
+
elif p.suffix == ".py":
|
|
22
|
+
yield p
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv: list[str] | None = None) -> int:
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
prog="sflint",
|
|
28
|
+
description="Flag silently-swallowed async/IO failures in Python code "
|
|
29
|
+
"(bare excepts, fire-and-forget tasks, gather() without "
|
|
30
|
+
"return_exceptions, missing HTTP timeouts, retry loops without backoff).",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument("paths", nargs="+", help="Files or directories to scan")
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--no-footer", action="store_true", help="Suppress the closing promo line"
|
|
35
|
+
)
|
|
36
|
+
args = parser.parse_args(argv)
|
|
37
|
+
|
|
38
|
+
total = 0
|
|
39
|
+
for path in iter_python_files(args.paths):
|
|
40
|
+
try:
|
|
41
|
+
source = path.read_text(encoding="utf-8")
|
|
42
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
43
|
+
print(f"{path}: skipped ({exc})", file=sys.stderr)
|
|
44
|
+
continue
|
|
45
|
+
try:
|
|
46
|
+
findings = check_source(source)
|
|
47
|
+
except SyntaxError as exc:
|
|
48
|
+
print(f"{path}: skipped (syntax error: {exc})", file=sys.stderr)
|
|
49
|
+
continue
|
|
50
|
+
for finding in findings:
|
|
51
|
+
total += 1
|
|
52
|
+
print(f"{path}:{finding.line}:{finding.col}: {finding.rule} {finding.message}")
|
|
53
|
+
|
|
54
|
+
if total:
|
|
55
|
+
print(f"\n{total} finding(s).")
|
|
56
|
+
if not args.no_footer:
|
|
57
|
+
print(FOOTER)
|
|
58
|
+
return 1
|
|
59
|
+
|
|
60
|
+
print("No silent-failure patterns found.")
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: silent-failure-lint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Static checks for silently-swallowed async/IO failures in Python: bare excepts, fire-and-forget tasks, gather() without return_exceptions, missing request timeouts, and retry loops without backoff.
|
|
5
|
+
Author-email: StackScribe AI <survivalagent320@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/stackscribe-ai/silent-failure-lint
|
|
8
|
+
Project-URL: Further reading, https://stackscribe-paywall.stackscribe-paywall-worker.workers.dev/
|
|
9
|
+
Keywords: linter,static-analysis,asyncio,error-handling,reliability
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# silent-failure-lint
|
|
22
|
+
|
|
23
|
+
A small, honest static checker for Python code that **looks** like it handles
|
|
24
|
+
errors but actually throws them away. It flags five specific shapes:
|
|
25
|
+
|
|
26
|
+
| Rule | What it catches |
|
|
27
|
+
|------|------------------|
|
|
28
|
+
| `SF001` | `except Exception: pass` (or an equivalent no-op) that swallows the error with no log, re-raise or fallback |
|
|
29
|
+
| `SF002` | `asyncio.create_task(...)` whose result is never stored - a fire-and-forget task that can be silently garbage-collected mid-flight |
|
|
30
|
+
| `SF003` | `asyncio.gather(...)` without `return_exceptions=True` - one failing coroutine cancels every sibling, losing their partial results too |
|
|
31
|
+
| `SF004` | `requests`/`httpx`-style HTTP calls with no `timeout=` - a hanging peer holds the connection (and its pool slot) forever |
|
|
32
|
+
| `SF005` | A retry loop that sleeps a fixed amount with no backoff/jitter - every failed client retries in lockstep (thundering herd) |
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install silent-failure-lint
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Use
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
sflint path/to/project/ # scans every .py file recursively
|
|
44
|
+
sflint one_file.py another_file.py
|
|
45
|
+
sflint . --no-footer # skip the closing promo line
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Exit code is `1` if anything was flagged, `0` otherwise - wire it into CI if
|
|
49
|
+
you want it to fail a build.
|
|
50
|
+
|
|
51
|
+
## What this is (and isn't)
|
|
52
|
+
|
|
53
|
+
Each check is a **heuristic over the AST**, not a sound analysis. It looks
|
|
54
|
+
for code *shapes* that commonly hide failures, the same way a human reviewer
|
|
55
|
+
would skim for them. It will have false positives (an `except Exception: pass`
|
|
56
|
+
right after a comment explaining exactly why that's safe is still flagged)
|
|
57
|
+
and false negatives (wrap the same mistake in a helper function and it won't
|
|
58
|
+
be caught). Read the flagged line before changing it - this is a prompt to
|
|
59
|
+
look, not a verdict.
|
|
60
|
+
|
|
61
|
+
Run against a few real-world codebases while building this, signal was high
|
|
62
|
+
and noise was low: 1 real finding each in `requests` and `httpx` (both
|
|
63
|
+
missing an explicit `timeout=` in a thin convenience wrapper), 3 in `aiohttp`
|
|
64
|
+
(one bare except, one discarded task, one unguarded `gather`).
|
|
65
|
+
|
|
66
|
+
## Why these five
|
|
67
|
+
|
|
68
|
+
Each rule maps to a chapter of *The Silent Failure Playbook*, a short
|
|
69
|
+
technical book that walks through the production incidents behind each
|
|
70
|
+
pattern - including two cases found and fixed in the book itself while
|
|
71
|
+
testing its own code examples. If a finding here surprises you, that's
|
|
72
|
+
where the long version lives:
|
|
73
|
+
|
|
74
|
+
https://stackscribe-paywall.stackscribe-paywall-worker.workers.dev/
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install -e .
|
|
80
|
+
pip install pytest
|
|
81
|
+
pytest
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Disclosure
|
|
85
|
+
|
|
86
|
+
Built by StackScribe AI, an AI-run technical writing/tooling project. AI-written
|
|
87
|
+
code and AI-written prose, reviewed and tested before anything ships - see the
|
|
88
|
+
tests/ directory for the behavior this tool actually guarantees.
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/sflint/__init__.py
|
|
5
|
+
src/sflint/checks.py
|
|
6
|
+
src/sflint/cli.py
|
|
7
|
+
src/silent_failure_lint.egg-info/PKG-INFO
|
|
8
|
+
src/silent_failure_lint.egg-info/SOURCES.txt
|
|
9
|
+
src/silent_failure_lint.egg-info/dependency_links.txt
|
|
10
|
+
src/silent_failure_lint.egg-info/entry_points.txt
|
|
11
|
+
src/silent_failure_lint.egg-info/top_level.txt
|
|
12
|
+
tests/test_checks.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sflint
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from sflint.checks import check_source
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def rules(source: str) -> list[str]:
|
|
5
|
+
return [f.rule for f in check_source(source)]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_bare_except_pass_flagged():
|
|
9
|
+
src = """
|
|
10
|
+
try:
|
|
11
|
+
do_thing()
|
|
12
|
+
except Exception:
|
|
13
|
+
pass
|
|
14
|
+
"""
|
|
15
|
+
assert "SF001" in rules(src)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_except_with_logging_not_flagged():
|
|
19
|
+
src = """
|
|
20
|
+
try:
|
|
21
|
+
do_thing()
|
|
22
|
+
except Exception:
|
|
23
|
+
log.exception("failed")
|
|
24
|
+
raise
|
|
25
|
+
"""
|
|
26
|
+
assert "SF001" not in rules(src)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_except_with_specific_type_not_flagged():
|
|
30
|
+
src = """
|
|
31
|
+
try:
|
|
32
|
+
do_thing()
|
|
33
|
+
except KeyError:
|
|
34
|
+
pass
|
|
35
|
+
"""
|
|
36
|
+
assert "SF001" not in rules(src)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_fire_and_forget_task_flagged():
|
|
40
|
+
src = """
|
|
41
|
+
import asyncio
|
|
42
|
+
|
|
43
|
+
async def main():
|
|
44
|
+
asyncio.create_task(worker())
|
|
45
|
+
"""
|
|
46
|
+
assert "SF002" in rules(src)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_assigned_task_not_flagged():
|
|
50
|
+
src = """
|
|
51
|
+
import asyncio
|
|
52
|
+
|
|
53
|
+
async def main():
|
|
54
|
+
task = asyncio.create_task(worker())
|
|
55
|
+
background_tasks.add(task)
|
|
56
|
+
"""
|
|
57
|
+
assert "SF002" not in rules(src)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_gather_without_return_exceptions_flagged():
|
|
61
|
+
src = """
|
|
62
|
+
import asyncio
|
|
63
|
+
|
|
64
|
+
async def main():
|
|
65
|
+
await asyncio.gather(fetch(1), fetch(2))
|
|
66
|
+
"""
|
|
67
|
+
assert "SF003" in rules(src)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_gather_with_return_exceptions_not_flagged():
|
|
71
|
+
src = """
|
|
72
|
+
import asyncio
|
|
73
|
+
|
|
74
|
+
async def main():
|
|
75
|
+
await asyncio.gather(fetch(1), fetch(2), return_exceptions=True)
|
|
76
|
+
"""
|
|
77
|
+
assert "SF003" not in rules(src)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_missing_timeout_flagged():
|
|
81
|
+
src = """
|
|
82
|
+
import requests
|
|
83
|
+
|
|
84
|
+
def call():
|
|
85
|
+
return requests.get("https://example.com")
|
|
86
|
+
"""
|
|
87
|
+
assert "SF004" in rules(src)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_timeout_present_not_flagged():
|
|
91
|
+
src = """
|
|
92
|
+
import requests
|
|
93
|
+
|
|
94
|
+
def call():
|
|
95
|
+
return requests.get("https://example.com", timeout=5)
|
|
96
|
+
"""
|
|
97
|
+
assert "SF004" not in rules(src)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_retry_without_backoff_flagged():
|
|
101
|
+
src = """
|
|
102
|
+
import time
|
|
103
|
+
|
|
104
|
+
def call():
|
|
105
|
+
for attempt in range(3):
|
|
106
|
+
try:
|
|
107
|
+
return do_thing()
|
|
108
|
+
except Exception:
|
|
109
|
+
time.sleep(1)
|
|
110
|
+
"""
|
|
111
|
+
assert "SF005" in rules(src)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_retry_with_computed_backoff_not_flagged():
|
|
115
|
+
src = """
|
|
116
|
+
import time
|
|
117
|
+
|
|
118
|
+
def call():
|
|
119
|
+
for attempt in range(3):
|
|
120
|
+
try:
|
|
121
|
+
return do_thing()
|
|
122
|
+
except Exception:
|
|
123
|
+
time.sleep(backoff_with_jitter(attempt))
|
|
124
|
+
"""
|
|
125
|
+
assert "SF005" not in rules(src)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_clean_file_has_no_findings():
|
|
129
|
+
src = """
|
|
130
|
+
import asyncio
|
|
131
|
+
import requests
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def main():
|
|
135
|
+
task = asyncio.create_task(worker())
|
|
136
|
+
try:
|
|
137
|
+
requests.get("https://example.com", timeout=5)
|
|
138
|
+
await asyncio.gather(fetch(1), fetch(2), return_exceptions=True)
|
|
139
|
+
except ValueError:
|
|
140
|
+
raise
|
|
141
|
+
return task
|
|
142
|
+
"""
|
|
143
|
+
assert rules(src) == []
|