Skip to content

Commit 7efbfbd

Browse files
committed
fix: keep a long, quiet request alive
Calls to the render service carry nothing on the connection for minutes at a time while the service works on the answer. Network equipment between the two drops a connection that quiet and tells neither end, so the answer has nowhere to go. The client then waits for an answer that cannot arrive, with no read timeout to end the wait and nothing on the connection to tell it the other end is gone. Every request now goes through a session whose connections carry keepalive. The probes put a packet on the connection often enough to keep it, and they end the connection when the other end has really gone, which the retry logic already treats as a network error. A render of the dashboard waited one hour and fifty-six minutes this way. The service had answered it with 200 after six and a half minutes.
1 parent 8a35fd2 commit 7efbfbd

1 file changed

Lines changed: 49 additions & 1 deletion

File tree

‎codeplain_REST_api.py‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import socket
12
import time
23
from typing import Optional
34

45
import requests
6+
from requests.adapters import HTTPAdapter
57
from requests.exceptions import ConnectionError, RequestException, Timeout
8+
from urllib3.connection import HTTPConnection
69

710
import plain2code_exceptions
811
from plain2code_console import RETRY_COLOR
@@ -15,6 +18,50 @@
1518
# at exit, and requests waits indefinitely by default.
1619
REPORT_TIMEOUT_SECONDS = 5
1720

21+
# A render call can leave the connection with nothing on it for several minutes while the
22+
# service works on the answer. Network equipment between the two, such as the address
23+
# translation a CI runner sits behind, drops a connection that quiet and tells neither end.
24+
# The answer then has nowhere to go and the client waits for it for as long as it is allowed
25+
# to run. These probes put a packet on the connection often enough to keep it, and they end
26+
# it when the other end has really gone.
27+
KEEPALIVE_IDLE_SECONDS = 60
28+
KEEPALIVE_INTERVAL_SECONDS = 30
29+
KEEPALIVE_FAILED_PROBES = 4
30+
31+
32+
def keepalive_socket_options():
33+
"""The socket options that keep a long, quiet request alive, for the platforms that have them."""
34+
options = list(HTTPConnection.default_socket_options)
35+
options.append((socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1))
36+
# The option that sets the idle time is named differently per platform, and Windows has
37+
# none of the three. Whatever is missing is left out; the keepalive itself still applies.
38+
for idle_option in ("TCP_KEEPIDLE", "TCP_KEEPALIVE"):
39+
if hasattr(socket, idle_option):
40+
options.append((socket.IPPROTO_TCP, getattr(socket, idle_option), KEEPALIVE_IDLE_SECONDS))
41+
break
42+
if hasattr(socket, "TCP_KEEPINTVL"):
43+
options.append((socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, KEEPALIVE_INTERVAL_SECONDS))
44+
if hasattr(socket, "TCP_KEEPCNT"):
45+
options.append((socket.IPPROTO_TCP, socket.TCP_KEEPCNT, KEEPALIVE_FAILED_PROBES))
46+
return options
47+
48+
49+
class KeepaliveAdapter(HTTPAdapter):
50+
"""Opens every connection with keepalive on."""
51+
52+
def init_poolmanager(self, *args, **kwargs):
53+
kwargs["socket_options"] = keepalive_socket_options()
54+
return super().init_poolmanager(*args, **kwargs)
55+
56+
57+
def new_session():
58+
"""A requests session whose connections carry keepalive."""
59+
session = requests.Session()
60+
session.mount("https://", KeepaliveAdapter())
61+
session.mount("http://", KeepaliveAdapter())
62+
return session
63+
64+
1865
RETRY_ERROR_CODES = [
1966
"LLMInternalError",
2067
]
@@ -36,6 +83,7 @@ class CodeplainAPI:
3683
def __init__(self, api_key, console):
3784
self.api_key = api_key
3885
self.console = console
86+
self.session = new_session()
3987

4088
@property
4189
def api_url(self):
@@ -139,7 +187,7 @@ def post_request(
139187

140188
for attempt in range(num_retries + 1):
141189
try:
142-
response = requests.post(endpoint_url, headers=headers, json=payload, timeout=timeout)
190+
response = self.session.post(endpoint_url, headers=headers, json=payload, timeout=timeout)
143191

144192
try:
145193
response_json = response.json()

0 commit comments

Comments
 (0)