Skip to content

Commit 1178aa0

Browse files
committed
Improve http.Client connection handling
HTTP 1.0 servers close the connection automatically after sending the response. We updated the http.Client actor such that the users can use the high-level API like making several requests in a row without having to worry about reconnects. http.Client will buffer the requests and flush the buffer once it (re)connects the socket. Writing to a closed socket is bad and guaranteed to fail, so the user (in this case the http.Client actor) should be notified ASAP before making further writes. Rather than using the on_error callback from the underlying TCPConnection and TLSConnection actors we raise an exception from their write() methods. This implies the calls to write() are synchronous, but since we're not waiting on network I/O it should be fine?!
1 parent 08bd695 commit 1178aa0

2 files changed

Lines changed: 57 additions & 21 deletions

File tree

base/src/http.act

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,7 @@ actor Listener(cap: net.TCPListenCap, address: str, port: int, on_listen_error:
447447
# TODO: default schema="https"
448448
# TODO: default port=None
449449
# TODO: default tls_verify=True
450+
# TODO: add arguments for configuring request buffering (size) and reconnection attempts and timeouts
450451
actor Client(cap: net.TCPConnectCap, scheme: str, address: str, port: ?int, tls_verify: bool, on_connect: action(Client) -> None, on_error: action(Client, str) -> None, log_handler: ?logging.Handler):
451452
"""HTTP(S) Client
452453

@@ -457,10 +458,11 @@ actor Client(cap: net.TCPConnectCap, scheme: str, address: str, port: ?int, tls_
457458
var _on_response: list[(bytes, action(Client, Response) -> None)] = []
458459
var version: ?bytes = None
459460
var buf = b""
460-
var close_connection: bool = True
461461
var tcp_conn: ?net.TCPConnection = None
462462
var tls_conn: ?net.TLSConnection = None
463463

464+
var connecting: bool = True
465+
464466
def _connect():
465467
if scheme == "http":
466468
_log.verbose("Using http scheme and port 80", None)
@@ -475,9 +477,18 @@ actor Client(cap: net.TCPConnectCap, scheme: str, address: str, port: ?int, tls_
475477

476478
def _on_conn_connect():
477479
# If there are outstanding requests, it probably means we were
480+
# disconnected or have not connected yet
481+
# TODO: do not flush entire buffer if we know the server will close the
482+
# connection. If the latency is so big that we're able to send the
483+
# entire buffer before the server closes the connection we're just
484+
# wasting resources.
478485
for r in _on_response:
486+
_log.trace("Sending outstanding request", {"request": r.0})
479487
_conn_write(r.0)
480-
await async on_connect(self)
488+
if connecting:
489+
# Dispatch the on_connect callback on first connect but not for reconnects
490+
await async on_connect(self)
491+
connecting = False
481492

482493
def _on_tcp_connect(conn: net.TCPConnection) -> None:
483494
_on_conn_connect()
@@ -500,10 +511,10 @@ actor Client(cap: net.TCPConnectCap, scheme: str, address: str, port: ?int, tls_
500511
r, buf = parse_response(buf, _log)
501512
if r is not None:
502513
if "connection" in r.headers and r.headers["connection"] == "close":
503-
close_connection = True
504-
_conn_close()
514+
# Is this really the right thing to do here? If the client
515+
# does not make a new request we just reconnected for nothing!
505516
_log.debug("Closing TCP connection due to header: Connection: close", None)
506-
_connect()
517+
_conn_reconnect()
507518
if len(_on_response) == 0:
508519
_log.notice("Data received with no on_response callback set", None)
509520
break
@@ -524,22 +535,31 @@ actor Client(cap: net.TCPConnectCap, scheme: str, address: str, port: ?int, tls_
524535
def _on_con_error(error: str) -> None:
525536
on_error(self, error)
526537

527-
def _conn_close() -> None:
538+
def _conn_reconnect() -> None:
528539
if tcp_conn is not None:
529-
def _noop(c):
530-
pass
531-
tcp_conn.close(_noop)
540+
tcp_conn.reconnect()
532541
elif tls_conn is not None:
533-
def _noop(c):
534-
pass
535-
tls_conn.close(_noop)
542+
tls_conn.reconnect()
536543

537544
def _conn_write(data: bytes) -> None:
538-
_log.trace("Sending data", {"data": data})
539-
if tcp_conn is not None:
540-
tcp_conn.write(data)
541-
elif tls_conn is not None:
542-
tls_conn.write(data)
545+
try:
546+
_log.trace("Sending data", {"data": data})
547+
# We call the write method on the TCP or TLS connection actor
548+
# synchronously because we want to be able to catch exceptions
549+
# signaling the socket was closed. Note that this is *not* waiting
550+
# for network I/O, just waiting on system I/O for writing to the
551+
# local socket buffer.
552+
if tcp_conn is not None:
553+
await async tcp_conn.write(data)
554+
elif tls_conn is not None:
555+
await async tls_conn.write(data)
556+
except RuntimeError as exc:
557+
# HTTP/1.0 servers close the connection after each request by default
558+
if "bad file descriptor" in str(exc) or "bad stream" in str(exc):
559+
_log.debug("TCP connection closed, reconnecting", {"error": str(exc)})
560+
_conn_reconnect()
561+
else:
562+
_on_con_error(str(exc))
543563

544564
# HTTP methods
545565
def get(path: str, headers: dict[str, str], on_response: action(Client, Response) -> None):

base/src/net.ext.c

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,12 @@ void on_connect6(uv_connect_t *connect_req, int status) {
276276
char errmsg[1024] = "Failed to write to TCP socket: ";
277277
uv_strerror_r(r, errmsg + strlen(errmsg), sizeof(errmsg)-strlen(errmsg));
278278
log_warn(errmsg);
279+
if (strstr(errmsg, "bad file descriptor")) {
280+
// This can happen if the socket is closed, se we raise an exception
281+
// and let the caller retry
282+
$RAISE(((B_BaseException)B_RuntimeErrorG_new(to$str(errmsg))));
283+
return $R_CONT(c$cont, B_None);
284+
}
279285
$action2 f = ($action2)self->on_error;
280286
f->$class->__asyn__(f, self, to$str(errmsg));
281287
}
@@ -311,6 +317,9 @@ static void after_shutdown(uv_shutdown_t* req, int status) {
311317
uv_stream_t *stream = (uv_stream_t *)from$int(self->_sock);
312318
// fd == -1 means invalid FD and can happen after __resume__
313319
if (stream == -1)
320+
// TODO: should we dispatch the on_close callback here too even though
321+
// we did not close anything? This is what we do for TLSConnection too
322+
// and it allows for chaining the callbacks
314323
return $R_CONT(c$cont, B_None);
315324

316325
log_debug("Closing TCP connection");
@@ -629,9 +638,11 @@ void tls_write_cb(uv_write_t *wreq, int status) {
629638

630639
$R netQ_TLSConnectionD_closeG_local (netQ_TLSConnection self, $Cont c$cont, $action on_close) {
631640
uv_stream_t *stream = (uv_stream_t *)from$int(self->_stream);
632-
// fd == -1 means invalid FD and can happen after __resume__
633-
if (stream == -1)
641+
// fd == -1 means invalid FD and can happen after __resume__ or if the socket is closed
642+
if (stream == -1) {
643+
on_close->$class->__asyn__(on_close, self);
634644
return $R_CONT(c$cont, B_None);
645+
}
635646

636647
self->_on_close = on_close;
637648

@@ -645,9 +656,14 @@ void tls_write_cb(uv_write_t *wreq, int status) {
645656

646657
$R netQ_TLSConnectionD_writeG_local (netQ_TLSConnection self, $Cont c$cont, B_bytes data) {
647658
uv_stream_t *stream = (uv_stream_t *)from$int(self->_stream);
648-
// fd == -1 means invalid FD and can happen after __resume__
649-
if (stream == -1)
659+
// fd == -1 means invalid FD and can happen after __resume__ or if the socket is closed
660+
if (stream == -1) {
661+
// Raise an exception and let the caller retry
662+
char errmsg[] = "Failed to write to TLS TCP socket: bad stream";
663+
log_debug(errmsg);
664+
$RAISE(((B_BaseException)B_RuntimeErrorG_new(to$str(errmsg))));
650665
return $R_CONT(c$cont, B_None);
666+
}
651667

652668
uv_write_t *wreq = (uv_write_t *)malloc(sizeof(uv_write_t));
653669
wreq->data = self;

0 commit comments

Comments
 (0)