Skip to content

Commit af07ce9

Browse files
manan164claude
andcommitted
Auto-fall back to HTTP/1.1 on HTTP/2 protocol errors
Long-lived HTTP/2 connections through some proxies/load balancers (e.g. GCP Cloud Run, AWS ALB) can produce protocol-level errors (GOAWAY storms, stale keep-alive resets). The existing self-healing reset rebuilt another HTTP/2 client that hit the same wall, so the poll loop could cycle reset->fail->reset and effectively stop polling with no CPU/memory spike. On a ProtocolError/ReadError/WriteError, if HTTP/2 is enabled, the reset now downgrades to HTTP/1.1 for the remainder of the process (sticky) instead of rebuilding HTTP/2. Default-on HTTP/2 behavior is unchanged for healthy environments. Opt out of the fallback with CONDUCTOR_HTTP2_AUTO_FALLBACK=false. Applied to both the sync (rest.py) and async (async_rest.py) clients; added unit tests for both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d9f06f9 commit af07ce9

4 files changed

Lines changed: 174 additions & 5 deletions

File tree

src/conductor/client/http/async_rest.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import json
2+
import logging
23
import os
34
import re
45

56
import httpx
67
from six.moves.urllib.parse import urlencode
78

9+
logger = logging.getLogger(__name__)
10+
811

912
class RESTResponse:
1013

@@ -44,6 +47,8 @@ def getheaders(self):
4447

4548
class AsyncRESTClientObject(object):
4649
def __init__(self, connection=None):
50+
# Set once we fall back from HTTP/2 to HTTP/1.1 after a protocol error.
51+
self._http2_downgraded = False
4752
if connection is None:
4853
self._http2_enabled = self._is_http2_enabled()
4954
self.connection = self._create_default_httpx_client()
@@ -57,6 +62,13 @@ def _is_http2_enabled(self) -> bool:
5762
val = os.getenv("CONDUCTOR_HTTP2_ENABLED", "true").strip().lower()
5863
return val not in ("0", "false", "no", "off")
5964

65+
def _is_http2_auto_fallback_enabled(self) -> bool:
66+
# A protocol-level error on an HTTP/2 connection triggers a one-way
67+
# fallback to HTTP/1.1 for the rest of the process. Opt out with
68+
# CONDUCTOR_HTTP2_AUTO_FALLBACK=false to keep retrying on HTTP/2.
69+
val = os.getenv("CONDUCTOR_HTTP2_AUTO_FALLBACK", "true").strip().lower()
70+
return val not in ("0", "false", "no", "off")
71+
6072
def _create_default_httpx_client(self) -> httpx.AsyncClient:
6173
limits = httpx.Limits(
6274
max_connections=100, # Total connections across all hosts
@@ -77,9 +89,21 @@ def _create_default_httpx_client(self) -> httpx.AsyncClient:
7789
http2=bool(self._http2_enabled)
7890
)
7991

80-
async def _reset_connection(self) -> None:
92+
async def _reset_connection(self, downgrade_http2=False) -> None:
8193
if not getattr(self, "_owns_connection", False):
8294
return
95+
if (downgrade_http2
96+
and getattr(self, "_http2_enabled", None)
97+
and self._is_http2_auto_fallback_enabled()):
98+
# Protocol error on a long-lived HTTP/2 connection — fall back to
99+
# HTTP/1.1 for the rest of this process instead of rebuilding
100+
# another HTTP/2 client that stalls the poll loop the same way.
101+
self._http2_enabled = False
102+
self._http2_downgraded = True
103+
logger.warning(
104+
"httpx protocol error on HTTP/2 connection; disabled HTTP/2 "
105+
"and fell back to HTTP/1.1 for this process"
106+
)
83107
try:
84108
if getattr(self, "connection", None) is not None:
85109
await self.connection.aclose()
@@ -182,7 +206,9 @@ async def request(self, method, url, query_params=None, headers=None,
182206
break
183207
except (httpx.ProtocolError, httpx.ReadError, httpx.WriteError) as e:
184208
if attempt == 0 and self._owns_connection:
185-
await self._reset_connection()
209+
await self._reset_connection(
210+
downgrade_http2=bool(getattr(self, "_http2_enabled", False))
211+
)
186212
if method in idempotent_methods:
187213
continue
188214
msg = f"Protocol error ({type(e).__name__}): {e}"

src/conductor/client/http/rest.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ def __init__(self, connection=None):
6464
# discovering the same broken connection produces at most ONE real
6565
# reset + warning line, not N.
6666
self._reset_lock = threading.Lock()
67+
# Set once we fall back from HTTP/2 to HTTP/1.1 after a protocol error,
68+
# so we don't keep rebuilding HTTP/2 clients that hit the same wall.
69+
self._http2_downgraded = False
6770
if connection is None:
6871
self._http2_enabled = self._is_http2_enabled()
6972
self.connection = self._create_default_httpx_client()
@@ -78,6 +81,13 @@ def _is_http2_enabled(self) -> bool:
7881
val = os.getenv("CONDUCTOR_HTTP2_ENABLED", "true").strip().lower()
7982
return val not in ("0", "false", "no", "off")
8083

84+
def _is_http2_auto_fallback_enabled(self) -> bool:
85+
# When HTTP/2 is enabled, a protocol-level error on the connection
86+
# triggers a one-way fallback to HTTP/1.1 for the rest of the process.
87+
# Opt out with CONDUCTOR_HTTP2_AUTO_FALLBACK=false to keep retrying on HTTP/2.
88+
val = os.getenv("CONDUCTOR_HTTP2_AUTO_FALLBACK", "true").strip().lower()
89+
return val not in ("0", "false", "no", "off")
90+
8191
def _create_default_httpx_client(self) -> httpx.Client:
8292
# Create httpx client with connection pooling.
8393
# Use HTTP/2 when enabled (default), but allow opting out via CONDUCTOR_HTTP2_ENABLED.
@@ -111,9 +121,17 @@ def _is_client_closed(self) -> bool:
111121
except Exception:
112122
return False
113123

114-
def _reset_connection(self, expected=None) -> bool:
124+
def _reset_connection(self, expected=None, downgrade_http2=False) -> bool:
115125
"""Close the current httpx client (if any) and create a fresh one.
116126
127+
When `downgrade_http2` is True and HTTP/2 is currently enabled, the
128+
replacement client is built on HTTP/1.1 and HTTP/2 stays disabled for
129+
the remainder of this process. This is the auto-fallback path: a
130+
protocol-level error on a long-lived HTTP/2 connection (common with
131+
proxies/LBs that mishandle h2, e.g. GOAWAY storms) would otherwise be
132+
"healed" into another HTTP/2 client that fails the same way, stalling
133+
the poll loop. Downgrading once breaks that cycle.
134+
117135
This is a thread-safe compare-and-swap:
118136
119137
- If `expected` is provided, the reset is only performed when the
@@ -135,6 +153,11 @@ def _reset_connection(self, expected=None) -> bool:
135153
if expected is not None and current is not expected:
136154
# Someone else already healed since our caller last looked.
137155
return False
156+
if (downgrade_http2
157+
and getattr(self, "_http2_enabled", None)
158+
and self._is_http2_auto_fallback_enabled()):
159+
self._http2_enabled = False
160+
self._http2_downgraded = True
138161
try:
139162
if current is not None:
140163
current.close()
@@ -262,9 +285,20 @@ def request(self, method, url, query_params=None, headers=None,
262285
# Reset the client to recover without requiring process restart.
263286
# Only auto-retry idempotent methods to avoid duplicating side effects.
264287
if attempt == 0:
265-
reset_done = self._reset_connection(expected=client_at_send)
288+
# If we're on HTTP/2, treat a protocol error as a signal to
289+
# fall back to HTTP/1.1 for the rest of this process.
290+
downgrade = bool(getattr(self, "_http2_enabled", False))
291+
reset_done = self._reset_connection(
292+
expected=client_at_send, downgrade_http2=downgrade
293+
)
266294
if reset_done:
267-
logger.warning("httpx protocol error; re-established client: %s", e)
295+
if downgrade and getattr(self, "_http2_downgraded", False):
296+
logger.warning(
297+
"httpx protocol error on HTTP/2 connection; disabled HTTP/2 "
298+
"and fell back to HTTP/1.1 for this process: %s", e
299+
)
300+
else:
301+
logger.warning("httpx protocol error; re-established client: %s", e)
268302
else:
269303
logger.debug(
270304
"httpx protocol error on stale client (already healed by another thread): %s",
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import asyncio
2+
import unittest
3+
from unittest.mock import AsyncMock, MagicMock, patch
4+
5+
import httpx
6+
7+
from conductor.client.http import async_rest
8+
9+
10+
def _ok_response():
11+
response = MagicMock()
12+
response.status_code = 200
13+
response.reason_phrase = "OK"
14+
response.headers = {}
15+
response.text = ""
16+
return response
17+
18+
19+
def _mock_async_client():
20+
"""An httpx.AsyncClient stand-in whose request/aclose are awaitable."""
21+
c = MagicMock()
22+
c.request = AsyncMock()
23+
c.aclose = AsyncMock()
24+
return c
25+
26+
27+
class TestAsyncRESTClientObject(unittest.TestCase):
28+
@patch.dict("os.environ", {"CONDUCTOR_HTTP2_ENABLED": "true"})
29+
@patch.object(async_rest.AsyncRESTClientObject, "_create_default_httpx_client")
30+
def test_http2_protocol_error_downgrades_to_http1(self, mock_create_client):
31+
first_client = _mock_async_client()
32+
second_client = _mock_async_client()
33+
mock_create_client.side_effect = [first_client, second_client]
34+
35+
first_client.request.side_effect = httpx.RemoteProtocolError("ConnectionTerminated")
36+
second_client.request.return_value = _ok_response()
37+
38+
client = async_rest.AsyncRESTClientObject(connection=None)
39+
self.assertTrue(client._http2_enabled) # default on
40+
41+
result = asyncio.run(client.request("GET", "http://example"))
42+
43+
self.assertEqual(result.status, 200)
44+
self.assertFalse(client._http2_enabled) # HTTP/2 turned off
45+
self.assertTrue(client._http2_downgraded)
46+
self.assertTrue(first_client.aclose.called)
47+
48+
@patch.dict("os.environ", {"CONDUCTOR_HTTP2_ENABLED": "true",
49+
"CONDUCTOR_HTTP2_AUTO_FALLBACK": "false"})
50+
@patch.object(async_rest.AsyncRESTClientObject, "_create_default_httpx_client")
51+
def test_http2_auto_fallback_can_be_disabled(self, mock_create_client):
52+
first_client = _mock_async_client()
53+
second_client = _mock_async_client()
54+
mock_create_client.side_effect = [first_client, second_client]
55+
56+
first_client.request.side_effect = httpx.RemoteProtocolError("ConnectionTerminated")
57+
second_client.request.return_value = _ok_response()
58+
59+
client = async_rest.AsyncRESTClientObject(connection=None)
60+
result = asyncio.run(client.request("GET", "http://example"))
61+
62+
self.assertEqual(result.status, 200)
63+
self.assertTrue(client._http2_enabled) # still HTTP/2
64+
self.assertFalse(client._http2_downgraded)
65+
66+
67+
if __name__ == "__main__":
68+
unittest.main()

tests/unit/api_client/test_rest_client.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,47 @@ def test_resets_and_retries_on_remote_protocol_error(self, mock_create_client):
4444
self.assertTrue(first_client.close.called)
4545
self.assertEqual(result.status, 200)
4646

47+
@patch.dict("os.environ", {"CONDUCTOR_HTTP2_ENABLED": "true"})
48+
@patch.object(rest.RESTClientObject, "_create_default_httpx_client")
49+
def test_http2_protocol_error_downgrades_to_http1(self, mock_create_client):
50+
"""A protocol error on an HTTP/2 connection disables HTTP/2 for the
51+
rest of the process and heals onto HTTP/1.1."""
52+
first_client = _mock_client()
53+
second_client = _mock_client()
54+
mock_create_client.side_effect = [first_client, second_client]
55+
56+
first_client.request.side_effect = httpx.RemoteProtocolError("ConnectionTerminated")
57+
second_client.request.return_value = _ok_response()
58+
59+
client = rest.RESTClientObject(connection=None)
60+
self.assertTrue(client._http2_enabled) # default on
61+
62+
result = client.request("GET", "http://example")
63+
64+
self.assertEqual(result.status, 200)
65+
self.assertFalse(client._http2_enabled) # HTTP/2 turned off
66+
self.assertTrue(client._http2_downgraded)
67+
68+
@patch.dict("os.environ", {"CONDUCTOR_HTTP2_ENABLED": "true",
69+
"CONDUCTOR_HTTP2_AUTO_FALLBACK": "false"})
70+
@patch.object(rest.RESTClientObject, "_create_default_httpx_client")
71+
def test_http2_auto_fallback_can_be_disabled(self, mock_create_client):
72+
"""With CONDUCTOR_HTTP2_AUTO_FALLBACK=false we still self-heal, but
73+
stay on HTTP/2 instead of downgrading."""
74+
first_client = _mock_client()
75+
second_client = _mock_client()
76+
mock_create_client.side_effect = [first_client, second_client]
77+
78+
first_client.request.side_effect = httpx.RemoteProtocolError("ConnectionTerminated")
79+
second_client.request.return_value = _ok_response()
80+
81+
client = rest.RESTClientObject(connection=None)
82+
result = client.request("GET", "http://example")
83+
84+
self.assertEqual(result.status, 200)
85+
self.assertTrue(client._http2_enabled) # still HTTP/2
86+
self.assertFalse(client._http2_downgraded)
87+
4788
def test_is_closed_client_error_recognises_httpx_messages(self):
4889
self.assertTrue(
4990
rest._is_closed_client_error(

0 commit comments

Comments
 (0)