SwarmAudit / app /agents /error_handling_agent.py
Pranoy Mukherjee
Update SwarmAudit Space demo
9237011
Raw
History Blame Contribute Delete
7.81 kB
import re
from app.schemas import AgentOutput, CodeChunk, Finding, Severity
EXCEPT_LINE = re.compile(r"^\s*except(?:\s+([\w.]+))?.*:")
REQUEST_WITHOUT_TIMEOUT = re.compile(r"\brequests\.(get|post|put|patch|delete)\s*\((?!.*\btimeout\s*=)")
JS_FETCH_WITHOUT_ABORT = re.compile(r"\bfetch\s*\([^,\n)]+\)")
LOGGING_SIGNALS = ("logging.", "logger.", ".exception(", ".error(", ".warning(", "console.error", "console.warn")
class ErrorHandlingAgent:
name = "Error Handling Agent"
async def analyze(self, chunks: list[CodeChunk]) -> AgentOutput:
findings: list[Finding] = []
for chunk in chunks:
findings.extend(self._scan_chunk(chunk))
return AgentOutput(
agent_name=self.name,
findings=findings,
metadata={"chunks_scanned": len(chunks), "mode": "static-rules"},
)
def _scan_chunk(self, chunk: CodeChunk) -> list[Finding]:
findings: list[Finding] = []
lines = chunk.content.splitlines()
for index, line in enumerate(lines):
actual_line = chunk.line_start + index
stripped = line.strip()
except_match = EXCEPT_LINE.match(line)
if except_match:
findings.extend(self._scan_except_block(chunk, lines, index, actual_line, except_match.group(1)))
if REQUEST_WITHOUT_TIMEOUT.search(line):
call_snippet = self._snippet(line)
findings.append(
self._finding(
"External HTTP call without timeout",
Severity.medium,
chunk,
actual_line,
f"`{call_snippet}` makes an external request without an explicit timeout.",
f"Add `timeout=` to `{call_snippet}` and handle timeout exceptions with logging or retry policy.",
0.84,
why_it_matters=(
"This exact call can hold the worker until the operating system or remote service gives up, "
"which makes downstream outages spread into the app."
),
)
)
if JS_FETCH_WITHOUT_ABORT.search(line) and "AbortController" not in chunk.content:
call_snippet = self._snippet(line)
findings.append(
self._finding(
"Fetch call has no cancellation timeout",
Severity.low,
chunk,
actual_line,
f"`{call_snippet}` uses fetch without an AbortController or deadline in this scanned chunk.",
"Wrap this fetch in an AbortController timeout or a shared HTTP client that enforces request deadlines.",
0.76,
why_it_matters="A stuck fetch can leave the user action or server-side request waiting with no bounded failure path.",
)
)
return findings
def _scan_except_block(
self,
chunk: CodeChunk,
lines: list[str],
except_index: int,
actual_line: int,
exception_name: str | None,
) -> list[Finding]:
block_lines = self._collect_block(lines, except_index)
normalized = "\n".join(line.strip() for line in block_lines)
findings: list[Finding] = []
if exception_name in (None, "Exception", "BaseException"):
exception_label = exception_name or "bare except"
findings.append(
self._finding(
"Broad exception handler",
Severity.medium,
chunk,
actual_line,
f"The handler catches `{exception_label}`, which can group unrelated failures into the same recovery path.",
f"Replace `{exception_label}` with the narrow exception type expected here, and let unexpected failures surface with context.",
0.82,
why_it_matters="Broad handlers make different failure modes look identical during incident triage.",
)
)
if not block_lines:
return findings
has_logging = any(signal in normalized for signal in LOGGING_SIGNALS)
reraises = re.search(r"(^|\n)raise(\s|$)", normalized) is not None
silent_body = normalized in {"pass", "..."} or normalized.startswith("return None")
if silent_body:
body_preview = self._snippet(normalized.splitlines()[0] if normalized else "empty handler")
findings.append(
self._finding(
"Exception swallowed without recovery",
Severity.high,
chunk,
actual_line,
f"The except block uses `{body_preview}` and suppresses the failure without logging, retrying, or returning a meaningful fallback.",
"Log the exception with local context, re-raise when the caller must handle it, or return a deliberate typed fallback.",
0.9,
why_it_matters="This handler erases the original failure at the exact point where debugging context is still available.",
)
)
elif not has_logging and not reraises:
first_action = self._snippet(normalized.splitlines()[0] if normalized else "handler body")
findings.append(
self._finding(
"Exception handled without logging or re-raise",
Severity.medium,
chunk,
actual_line,
f"The except block continues with `{first_action}` but does not log or re-raise the exception.",
"Add structured logging before this recovery path, or re-raise after adding recovery-specific context.",
0.82,
why_it_matters="The recovery branch may keep execution going while hiding why the branch was needed.",
)
)
return findings
def _collect_block(self, lines: list[str], except_index: int) -> list[str]:
except_line = lines[except_index]
except_indent = len(except_line) - len(except_line.lstrip(" "))
block: list[str] = []
for line in lines[except_index + 1 :]:
if not line.strip():
continue
indent = len(line) - len(line.lstrip(" "))
if indent <= except_indent:
break
block.append(line)
return block
def _finding(
self,
title: str,
severity: Severity,
chunk: CodeChunk,
line_number: int,
description: str,
suggested_fix: str,
confidence: float,
why_it_matters: str | None = None,
) -> Finding:
return Finding(
title=title,
severity=severity,
file_path=chunk.file_path,
line_start=line_number,
line_end=line_number,
description=description,
why_it_matters=why_it_matters
or "Weak error handling turns small downstream failures into outages that are hard to diagnose and recover from.",
suggested_fix=suggested_fix,
agent_source=self.name,
category="error_handling",
confidence=confidence,
)
def _snippet(self, line: str, max_length: int = 96) -> str:
normalized = " ".join(line.strip().split())
if len(normalized) <= max_length:
return normalized
return f"{normalized[: max_length - 3]}..."