Repository: pymodbus-dev/pymodbus

Issue:
Async Serial Client is closing connection if timeout happens during reading registers
<!--
Before opening a new issue, make sure you do the following:

- Check that your issue isn't already filed: https://github.com/pymodbus-dev/pymodbus/issues
- Check the discussions forum https://github.com/pymodbus-dev/pymodbus/discussions
- Prepare a short, runnable example that reproduce the issue with the latest development version of Pymodbus
-->

### Versions

- Python: 3.11
- OS: Windows
- Pymodbus: 3.3.2
- Modbus Hardware (if used): Multiple RTU devices are connected to a serial comport via a distribution box.

### Pymodbus Specific

- Server: none
- Client: rtu - async

### Description

I am trying to asynchronously read information from multiple slave devices connected to one client. My problem is as soon as a timeout accours at any device, the client is disconnected and all the other slaves as well. There are several issues regarding something similar but I could not find the solution for this problem. The synchronous version works properly, but when I open a single thread for an async event loop and trying to call the readings in this loop, I cant keep the client connected for a long period of time because of this issue. Does not matter how I am handling the exceptions, the client keeps closing itself. How can I keep the client connected? 

In this example I am deliberately using the wrong slave address but the same happens if the sensor is just not answering in time. Increasing the timeout is not a reliable solution, since I cant determine how long interval do I need to be on the safe side when the number of slaves increases. Thanks a lot in advance for your help!

### Code

```python
import sys
import asyncio
import threading
from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton, QWidget, QVBoxLayout, QTabWidget, QTextBrowser
from PyQt5.QtCore import QObject, pyqtSignal, Qt
import datetime
from pymodbus.client import AsyncModbusSerialClient
from pymodbus.transaction import ModbusRtuFramer
from functools import partial
import logging
from pymodbus.exceptions import ConnectionException


class SignalEmitter(QObject):
    data_signal = pyqtSignal(object, object)


class Tab(QWidget):
    def __init__(self, parent=None):
        super(Tab, self).__init__(parent)
        self.layout = QVBoxLayout(self)
        self.text_browser = QTextBrowser(self)
        self.layout.addWidget(self.text_browser)
        self.setLayout(self.layout)
        self.emitter = SignalEmitter()
        self.emitter.data_signal.connect(self.handle)

    def handle(self, data, debug_start):
        if debug_start is None:
            self.text_browser.append(str(data))
        else:
            debug_end = datetime.datetime.now()
            duration = debug_end - debug_start
            # print(duration)
            self.text_browser.append(str(duration.total_seconds()))


class TabWidget(QTabWidget):
    def __init__(self, parent=None):
        super(TabWidget, self).__init__(parent)

        self.addTab(Tab(), "Tab1")
        self.addTab(Tab(), "Tab2")


class MainWindow(QMainWindow):
    def __init__(self, parent=None):
        super().__init__(parent)

        self.thread = None
        self.lock = asyncio.Lock()
        self.loop = asyncio.new_event_loop()
        self.t = threading.Thread(target=self.start_background_loop, args=(self.loop,), daemon=True)
        self.t.start()
        central_widget = QWidget(self)
        self.setCentralWidget(central_widget)
        self.run_reading = False

        self.tab_widget = TabWidget()

        layout = QVBoxLayout(central_widget)
        
        slave_address = 4
        button1 = QPushButton("Connect", self)
        button1.clicked.connect(self.run_async_connect)
        layout.addWidget(button1)

        button2 = QPushButton("Sensor info", self)
        button2.clicked.connect(partial(self.run_async_sensor_info_reading, slave_address, self.tab_widget.widget(0).emitter))
        layout.addWidget(button2)

        layout.addWidget(self.tab_widget)

        self.modbus_client = AsyncModbusSerialClient(port="COM10",
                                                     framer=ModbusRtuFramer,
                                                     timeout=1,
                                                     baudrate=115200,
                                                     stopbits=2,
                                                     close_comm_on_error=False)

  
    def start_background_loop(self, loop: asyncio.AbstractEventLoop) -> None:
        asyncio.set_event_loop(loop)
        loop.run_forever()

    def run_async_sensor_info_reading(self, slave_address, emitter=None):
        debug_start = datetime.datetime.now()
        task = asyncio.run_coroutine_threadsafe(self.read_sensor_info(slave_address), self.loop)
        if emitter:
            task.add_done_callback(lambda t: emitter.data_signal.emit(t.result(), debug_start))

  
    def run_async_connect(self):
        asyncio.run_coroutine_threadsafe(self.create_connection(), self.loop)

    async def read_registers(self, address, count, slave):
        try:
            async with self.lock:
                payload = await self.modbus_client.read_holding_registers(address=address-1, count=count, slave=slave)
                return payload
        except ConnectionException as e:
            print(str(e))

        except TimeoutError as e:
            print(print(e))

    async def read_sensor_info(self, slave_address):
        tasks = []
        tasks.append(asyncio.create_task(self.read_registers(1288, 8, slave_address)))

        results = await asyncio.gather(*tasks)

        return results


    def connect(self):
        asyncio.get_event_loop().run_until_complete(self.create_connection())

    async def create_connection(self):
        await self.modbus_client.connect()
        print("Client connected: ", self.modbus_client.connected)


if __name__ == "__main__":
    logging.basicConfig()
    log = logging.getLogger()
    log.setLevel(logging.DEBUG)

    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec_())

```

### LOG

```text
DEBUG:asyncio:Using proactor: IocpProactor
DEBUG:pymodbus.logging:Connecting to None.
DEBUG:pymodbus.logging:Connecting comm
DEBUG:pymodbus.logging:Connected to comm
Client connected:  True
DEBUG:pymodbus.logging:send: 0x4 0x3 0x5 0x7 0x0 0x8 0xf5 0x54
DEBUG:pymodbus.logging:Adding transaction 4

None
DEBUG:pymodbus.logging:Connection lost comm due to None
DEBUG:pymodbus.logging:Getting transaction 4

Process finished with exit code -1
```



Discussion:
??? you get a connection error in the read, to tell that it was unsuccessful, that does not mean the connection is closed for good, it means it reconnect later. I have no idea what we should do, but to close the connection (and reconnect) if there is a faulty device. 

It seems however you have not requested reconnect, so to that effect the library does what you ask it to do.

pymodbus needs to inform that the call did not succeed so you can decide what to do, but if you do nothing, it will reconnect.



It would be interesting to hear about the other issues as well.
If I have several slaves connected to the same port, its quite problematic when the client disconnects and all the other sleave readings are stopped as well. If only one slave is not answering because of non fault related issues the client should read the others. If I click on the "Sensor info" button again in my GUI to initiate another reading, we can see that the client is disconnected for good. (I am using another device now, that is why its COM4).

```
DEBUG:asyncio:Using proactor: IocpProactor
DEBUG:pymodbus.logging:Connecting to None.
DEBUG:pymodbus.logging:Connecting comm
DEBUG:pymodbus.logging:Connected to comm
Client connected:  True
DEBUG:pymodbus.logging:send: 0x5 0x3 0x5 0x7 0x0 0x8 0xf4 0x85
DEBUG:pymodbus.logging:Adding transaction 5

None
DEBUG:pymodbus.logging:Connection lost comm due to None
DEBUG:pymodbus.logging:Getting transaction 5
Modbus Error: [Connection] Not connected[AsyncModbusSerialClient None:COM4]
```

If I look at the callstack, I dont understand why we have to close the client in the transport.py files connection_lost() function.:

```
connection_lost, transport.py:316
_call_connection_lost, __init__.py:434
_run, events.py:80
_run_once, base_events.py:1922
run_forever, base_events.py:607
run_forever, windows_events.py:321
start_background_loop, aio in thread2.py:114
run, threading.py:975
_bootstrap_inner, threading.py:1038
_bootstrap, threading.py:995
```

If we cant prevent the client from closing, how can I force it to reconnect the fastest possible way, and also to not get diconnected exceptions in the other async readings in the meantime?
pull requests are welcome.
When a command times out the client is disconnected..
I can confirm this happens with the async TCP client as well, this did not happen in previous versions.
@GraciousGpal Yes, but it then reconnects automatically if you have specified reconnect.
```python
close_comm_on_error=False
```
isnt the way of specifying to reconnect? If so, its not working!
No ! please read the documentation 
> @GraciousGpal Yes, but it then reconnects automatically if you have specified reconnect.

It does not reconnect for timeouts, 
` Connection lost comm due to None`
That is the last message i get in the debug, no re-connection attempt is made after. 
For other types of disconnections reconnect is triggered properly.

How do you determine that no reconnect was tried ?

If you use the async client it happens behind the scenes (assuming you let other async tasks work), if you use the sync client it happens with your next request.
However "Connection lost comm due to None" sounds wrong, so let me investigate.
It really seems to be because you do not configure the reconnect parameters.

Have a look at 
https://github.com/pymodbus-dev/pymodbus/blob/dev/test/sub_transport/test_reconnect.py

that is where we test all kind of reconnects, and that works.
Isn`t it possible for the async read_holding_registers() function to return with a ModbusIOException? That would make the whole a lot easier.
Posible is everything, but I am not convinced that changes anything in this case, either you have a "try: expect:" in your code or an "if".

however pull requests are welcome.
> It really seems to be because you do not configure the reconnect parameters.
> 
> Have a look at https://github.com/pymodbus-dev/pymodbus/blob/dev/test/sub_transport/test_reconnect.py
> 
> that is where we test all kind of reconnects, and that works.

EDIT: To add debug parameter to pymodbus and specify delay parameter.
Here is a reproducible example using the MODBUS server, :
```py
import asyncio
import logging
from time import time

import pymodbus
from pymodbus.client import AsyncModbusTcpClient
from pymodbus.datastore import ModbusSlaveContext, ModbusSequentialDataBlock, ModbusServerContext
from pymodbus.device import ModbusDeviceIdentification
from pymodbus.framer import ModbusSocketFramer
from pymodbus.server import StartAsyncTcpServer
from pymodbus.server.async_io import ModbusConnectedRequestHandler

logging.basicConfig(format='%(asctime)s:%(module)s:%(filename)s:%(funcName)s:%(levelname)s:%(message)s')
log = logging.getLogger()
log.setLevel(logging.DEBUG)


class ModifiedHandler(ModbusConnectedRequestHandler):
    """
    Makes the server lazy and not respond to requests.
    Thus force a timeout.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def data_received(self, data):
        """Call when some data is received.

        data is a non-empty bytes object containing the incoming data.
        """
        pass


async def run_commands(no_of_cmd_retries=100):
    # wait for server to start
    await asyncio.sleep(2)

    # Let client connect
    await client.connect()

    for i in range(no_of_cmd_retries):

        if i == 0:
            client.connection_lost(RuntimeError("Connection lost"))
        else:
            start = time()
            # Random command for timeout
            try:
                await client.write_registers(address=0, values=3, slave=0)
                log.warning(f"Took {round(time() - start, 2)}s to auto finish timeout")
            except asyncio.exceptions.TimeoutError:
                log.warning(f"Took {round(time() - start, 2)}s to timeout")
            except pymodbus.exceptions.ConnectionException:
                log.warning("Failed to Reconnect SAD")
        await asyncio.sleep(3)


def test_server(PORT):
    loop = asyncio.new_event_loop()
    datablock = ModbusSequentialDataBlock(0x00, [17] * 100)
    context = ModbusSlaveContext(
        di=datablock, co=datablock, hr=datablock, ir=datablock
    )
    context = ModbusServerContext(slaves=context, single=True)
    identity = ModbusDeviceIdentification(
        info_name={
            "VendorName": "Pymodbus",
            "ProductCode": "PM",
            "VendorUrl": "https://github.com/pymodbus-dev/pymodbus/",
            "ProductName": "Pymodbus Server",
            "ModelName": "Pymodbus Server",
            "MajorMinorRevision": '3.3.2',
        }
    )
    server = StartAsyncTcpServer(
        context=context,  # Data storage
        identity=identity,  # server identify
        address=('127.0.0.1', PORT),  # listen address
        framer=ModbusSocketFramer,  # The framer strategy to use
        handler=ModifiedHandler,
    )
    try:
        loop.run_until_complete(server)
    except KeyboardInterrupt:
        server.close()


if __name__ == "__main__":
    ADDRESS = "localhost"
    PORT = 5041
    UNIT = 1

    loop = asyncio.new_event_loop()
    server_task = loop.create_task(asyncio.to_thread(test_server, PORT))  # start server
    client = AsyncModbusTcpClient(host=ADDRESS, port=PORT, handler=ModifiedHandler, timeout=1, loop=loop,
                                  retries=1000, reconnect_delay=0.1, reconnect_delay_max=3, log='debug')
    try:
        loop.run_until_complete(run_commands(no_of_cmd_retries=10))
    finally:
        server_task.cancel()
        client.loop.close()
        loop.close()
```
This forces a disconnection at the first iteration and the rest are timeouts, first one reconnects properly the rest don't.
Earlier Iterations of this library reconnected at every exception, last version i was using was literally 3.0.0dev5, which i patched for  some quirks it had. The reason i bring this up is this is a big change in behaviour that isn't really stated anywhere that people can find.

when the app does client.connect() it must be successful, that is not retried.

After the client is properly connected, it should reconnect if the reconnect parameters are set.

your example might do that but it is quite complicated.

you should use the pymodbus log call to activate the pymodbus debug.
All 3.x. versions close connection on an error, so the big change you talk about is something I don't understand.

If the client do not reconnect then it is a bug, as I have shown we test at transport level that reconnect works, but I do not think we test it from app level.

you did not add the debug log, so it is impossible to see if the client tries to reconnect. Which it really should not do, because you do not specify reconnect_delay (and your max. is unnaturally short it is typically in minutes).
> All 3.x. versions close connection on an error, so the big change you talk about is something I don't understand.
> 
> If the client do not reconnect then it is a bug, as I have shown we test at transport level that reconnect works, but I do not think we test it from app level.
> 
> you did not add the debug log, so it is impossible to see if the client tries to reconnect. Which it really should not do, because you do not specify reconnect_delay (and your max. is unnaturally short it is typically in minutes).

I updated the example to have debug for pymodbus and here is the output log:
```
2023-07-18 22:17:01,142:selector_events:selector_events.py:__init__:DEBUG:Using selector: EpollSelector
2023-07-18 22:17:01,150:selector_events:selector_events.py:__init__:DEBUG:Using selector: EpollSelector
2023-07-18 22:17:01,151:logging:logging.py:info:INFO:Server(TCP) listening.
2023-07-18 22:17:03,146:logging:logging.py:debug:DEBUG:Connecting to localhost:5041.
2023-07-18 22:17:03,147:logging:logging.py:debug:DEBUG:Connecting comm
2023-07-18 22:17:03,153:logging:logging.py:debug:DEBUG:Connected to comm
2023-07-18 22:17:03,153:logging:logging.py:debug:DEBUG:Connection lost comm due to Connection lost
2023-07-18 22:17:03,154:logging:logging.py:debug:DEBUG:Connection lost comm due to None
2023-07-18 22:17:03,154:logging:logging.py:debug:DEBUG:Socket [('127.0.0.1', 5041)] opened
2023-07-18 22:17:03,154:logging:logging.py:debug:DEBUG:TCP client connection established [('127.0.0.1', 55550)]
2023-07-18 22:17:03,154:logging:logging.py:debug:DEBUG:Wait comm 100.0 ms before reconnecting.
2023-07-18 22:17:03,155:logging:logging.py:debug:DEBUG:Handler for stream [('127.0.0.1', 55550)] has been canceled
2023-07-18 22:17:03,155:logging:logging.py:debug:DEBUG:TCP client disconnected [('127.0.0.1', 55550)]
2023-07-18 22:17:03,255:logging:logging.py:debug:DEBUG:Connecting comm
2023-07-18 22:17:03,256:logging:logging.py:debug:DEBUG:Connected to comm
2023-07-18 22:17:03,257:logging:logging.py:debug:DEBUG:Socket [('127.0.0.1', 5041)] opened
2023-07-18 22:17:03,257:logging:logging.py:debug:DEBUG:TCP client connection established [('127.0.0.1', 55554)]
2023-07-18 22:17:06,157:logging:logging.py:debug:DEBUG:send: 0x0 0x1 0x0 0x0 0x0 0x9 0x0 0x10 0x0 0x0 0x0 0x1 0x2 0x0 0x3
2023-07-18 22:17:06,158:logging:logging.py:debug:DEBUG:Adding transaction 1
2023-07-18 22:17:07,158:rfid_base:rfid_base.py:run_commands:WARNING:Took 1.0s to timeout
2023-07-18 22:17:07,159:logging:logging.py:debug:DEBUG:Connection lost comm due to None
2023-07-18 22:17:07,159:logging:logging.py:debug:DEBUG:Getting transaction 1
2023-07-18 22:17:07,159:logging:logging.py:debug:DEBUG:Handler for stream [('127.0.0.1', 55554)] has been canceled
2023-07-18 22:17:07,159:logging:logging.py:debug:DEBUG:TCP client disconnected [('127.0.0.1', 55554)]
2023-07-18 22:17:10,162:rfid_base:rfid_base.py:run_commands:WARNING:Failed to Reconnect SAD
2023-07-18 22:17:13,166:rfid_base:rfid_base.py:run_commands:WARNING:Failed to Reconnect SAD
2023-07-18 22:17:16,170:rfid_base:rfid_base.py:run_commands:WARNING:Failed to Reconnect SAD
2023-07-18 22:17:19,174:rfid_base:rfid_base.py:run_commands:WARNING:Failed to Reconnect SAD
2023-07-18 22:17:22,178:rfid_base:rfid_base.py:run_commands:WARNING:Failed to Reconnect SAD
2023-07-18 22:17:25,183:rfid_base:rfid_base.py:run_commands:WARNING:Failed to Reconnect SAD
2023-07-18 22:17:28,187:rfid_base:rfid_base.py:run_commands:WARNING:Failed to Reconnect SAD
2023-07-18 22:17:31,190:rfid_base:rfid_base.py:run_commands:WARNING:Failed to Reconnect SAD
```
A took a closer look at your code, to understand the log, and I hope you are aware that in principle your code should no5 work.

StartAsyncTcpServer is an async function, but you call it from a sync method. It is not supposed to return until the server terminates!

If you want to do what you do, you should use the class instead, at least on dev that is documented.

Your call to connection_lost() seems to break the transport layer (on dev). Your handler class works with 3.2, maybe with 3.3 but not with dev.

But anyhow there are 2 strange things in the log, so I will setup a small test when I get time.
The sync code works because it is executed in another thread and will not block the asyncio loop  with a easier to use method:
asyncio.to_thread [Doc](https://docs.python.org/3/library/asyncio-task.html#running-in-threads).
see:
```py
server_task = loop.create_task(asyncio.to_thread(test_server, PORT))  # start server
```
I have found the issue on 3.2.2 which is the python package version.
The issue is although the reconnect parameter is specified in Transport.close() when timeout occurs, there is no creation of task to reconnect.

```py
class ModifiedAsyncModbusTcpClient(AsyncModbusTcpClient):
    """
    Workaround for version 3.2.2 for pymodbus client not reconnecting on timeout.
    This method override the Transport.close() method
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


    def close(self, reconnect: bool = False) -> None:
        """OVERRIDDEN METHOD FOR TRANSPORT.CLOSE()
        Close connection.

        :param reconnect: (default false), try to reconnect
        """
        if self.transport:
            if hasattr(self.transport, "abort"):
                self.transport.abort()
            self.transport.close()
            self.transport = None
        self.protocol = None
        if not reconnect and self.reconnect_task:
            self.reconnect_task.cancel()
            self.reconnect_task = None
        self.recv_buffer = b""
        if reconnect and not self.transport and not self.reconnect_task: # <---- changes
            self.reconnect_task = asyncio.create_task(self.reconnect_connect()) # <---- changes
```

This should work like intended for those on the current release and need a quick patch before updates.
```
2023-07-18 23:35:03,674:selector_events:selector_events.py:__init__:DEBUG:Using selector: EpollSelector
2023-07-18 23:35:03,681:selector_events:selector_events.py:__init__:DEBUG:Using selector: EpollSelector
2023-07-18 23:35:03,683:logging:logging.py:info:INFO:Server(TCP) listening.
2023-07-18 23:35:05,678:logging:logging.py:debug:DEBUG:Connecting to localhost:5043.
2023-07-18 23:35:05,679:logging:logging.py:debug:DEBUG:Connecting comm
2023-07-18 23:35:05,691:logging:logging.py:debug:DEBUG:Connected to comm
2023-07-18 23:35:05,692:logging:logging.py:debug:DEBUG:Connection lost comm due to Connection lost
2023-07-18 23:35:05,692:logging:logging.py:debug:DEBUG:Connection lost comm due to None
2023-07-18 23:35:05,692:logging:logging.py:debug:DEBUG:Wait comm 100.0 ms before reconnecting.
2023-07-18 23:35:05,693:logging:logging.py:debug:DEBUG:Socket [('127.0.0.1', 5043)] opened
2023-07-18 23:35:05,694:logging:logging.py:debug:DEBUG:TCP client connection established [('127.0.0.1', 41980)]
2023-07-18 23:35:05,695:logging:logging.py:debug:DEBUG:Handler for stream [('127.0.0.1', 41980)] has been canceled
2023-07-18 23:35:05,696:logging:logging.py:debug:DEBUG:TCP client disconnected [('127.0.0.1', 41980)]
2023-07-18 23:35:05,793:logging:logging.py:debug:DEBUG:Connecting comm
2023-07-18 23:35:05,798:logging:logging.py:debug:DEBUG:Connected to comm
2023-07-18 23:35:05,800:logging:logging.py:debug:DEBUG:Socket [('127.0.0.1', 5043)] opened
2023-07-18 23:35:05,800:logging:logging.py:debug:DEBUG:TCP client connection established [('127.0.0.1', 41986)]
2023-07-18 23:35:08,695:logging:logging.py:debug:DEBUG:send: 0x0 0x1 0x0 0x0 0x0 0x9 0x0 0x10 0x0 0x0 0x0 0x1 0x2 0x0 0x3
2023-07-18 23:35:08,696:logging:logging.py:debug:DEBUG:Adding transaction 1
2023-07-18 23:35:09,698:rfid_base:rfid_base.py:run_commands:WARNING:Took 1.0s to timeout
2023-07-18 23:35:09,699:logging:logging.py:debug:DEBUG:Connection lost comm due to None
2023-07-18 23:35:09,699:logging:logging.py:debug:DEBUG:Getting transaction 1
2023-07-18 23:35:09,700:logging:logging.py:debug:DEBUG:Wait comm 100.0 ms before reconnecting.
2023-07-18 23:35:09,701:logging:logging.py:debug:DEBUG:Handler for stream [('127.0.0.1', 41986)] has been canceled
2023-07-18 23:35:09,702:logging:logging.py:debug:DEBUG:TCP client disconnected [('127.0.0.1', 41986)]
2023-07-18 23:35:09,801:logging:logging.py:debug:DEBUG:Connecting comm
2023-07-18 23:35:09,806:logging:logging.py:debug:DEBUG:Connected to comm
2023-07-18 23:35:09,808:logging:logging.py:debug:DEBUG:Socket [('127.0.0.1', 5043)] opened
2023-07-18 23:35:09,809:logging:logging.py:debug:DEBUG:TCP client connection established [('127.0.0.1', 41996)]
2023-07-18 23:35:12,703:logging:logging.py:debug:DEBUG:send: 0x0 0x2 0x0 0x0 0x0 0x9 0x0 0x10 0x0 0x0 0x0 0x1 0x2 0x0 0x3
2023-07-18 23:35:12,703:logging:logging.py:debug:DEBUG:Adding transaction 2
2023-07-18 23:35:13,706:rfid_base:rfid_base.py:run_commands:WARNING:Took 1.0s to timeout
2023-07-18 23:35:13,707:logging:logging.py:debug:DEBUG:Connection lost comm due to None
2023-07-18 23:35:13,707:logging:logging.py:debug:DEBUG:Getting transaction 2
2023-07-18 23:35:13,707:logging:logging.py:debug:DEBUG:Wait comm 100.0 ms before reconnecting.
2023-07-18 23:35:13,709:logging:logging.py:debug:DEBUG:Handler for stream [('127.0.0.1', 41996)] has been canceled
2023-07-18 23:35:13,709:logging:logging.py:debug:DEBUG:TCP client disconnected [('127.0.0.1', 41996)]
2023-07-18 23:35:13,808:logging:logging.py:debug:DEBUG:Connecting comm
2023-07-18 23:35:13,809:logging:logging.py:debug:DEBUG:Connected to comm
2023-07-18 23:35:13,809:logging:logging.py:debug:DEBUG:Socket [('127.0.0.1', 5043)] opened
2023-07-18 23:35:13,810:logging:logging.py:debug:DEBUG:TCP client connection established [('127.0.0.1', 42002)]
```
Will do more tests later, but for now seems to work.
Pull requests are welcome, but I think that code are refactored on current dev.

Resolving patch:
diff --git a/examples/client_async.py b/examples/client_async.py
--- a/examples/client_async.py
+++ b/examples/client_async.py
@@ -38,6 +38,7 @@
     AsyncModbusTlsClient,
     AsyncModbusUdpClient,
 )
+from pymodbus.exceptions import ModbusIOException
 
 
 logging.basicConfig()
@@ -57,8 +58,10 @@ def setup_async_client(description=None, cmdline=None):
             port=args.port,  # on which port
             # Common optional paramers:
             framer=args.framer,
-            #    timeout=10,
-            #    retries=3,
+            timeout=5,
+            retries=3,
+            reconnect_delay=1,
+            reconnect_delay_max=10,
             #    retry_on_empty=False,
             #    close_comm_on_error=False,
             #    strict=True,
@@ -130,11 +133,14 @@ async def run_async_client(client, modbus_calls=None):
 
 async def run_a_few_calls(client):
     """Test connection works."""
-    rr = await client.read_coils(32, 1, slave=1)
-    assert len(rr.bits) == 8
-    rr = await client.read_holding_registers(4, 2, slave=1)
-    assert rr.registers[0] == 17
-    assert rr.registers[1] == 17
+    try:
+        rr = await client.read_coils(32, 1, slave=1)
+        assert len(rr.bits) == 8
+        rr = await client.read_holding_registers(4, 2, slave=1)
+        assert rr.registers[0] == 17
+        assert rr.registers[1] == 17
+    except ModbusIOException:
+        pass
 
 
 if __name__ == "__main__":
diff --git a/pymodbus/client/base.py b/pymodbus/client/base.py
--- a/pymodbus/client/base.py
+++ b/pymodbus/client/base.py
@@ -7,7 +7,7 @@
 from typing import Any, Callable
 
 from pymodbus.client.mixin import ModbusClientMixin
-from pymodbus.exceptions import ConnectionException
+from pymodbus.exceptions import ConnectionException, ModbusIOException
 from pymodbus.factory import ClientDecoder
 from pymodbus.framer import ModbusFramer
 from pymodbus.logging import Log
@@ -152,7 +152,10 @@ def register(self, custom_response_class: ModbusResponse) -> None:
 
     def close(self, reconnect=False) -> None:
         """Close connection."""
-        self.transport_close(reconnect=reconnect)
+        if reconnect:
+            self.connection_lost(asyncio.TimeoutError("Server not responding"))
+        else:
+            self.transport_close()
 
     def idle_time(self) -> float:
         """Time before initiating next transaction (call **sync**).
@@ -192,13 +195,21 @@ async def async_execute(self, request=None):
         if self.params.broadcast_enable and not request.slave_id:
             resp = b"Broadcast write sent - no response expected"
         else:
-            try:
-                resp = await asyncio.wait_for(
-                    req, timeout=self.comm_params.timeout_connect
-                )
-            except asyncio.exceptions.TimeoutError:
+            count = 0
+            while count < self.params.retries:
+                count += 1
+                try:
+                    resp = await asyncio.wait_for(
+                        req, timeout=self.comm_params.timeout_connect
+                    )
+                    break
+                except asyncio.exceptions.TimeoutError:
+                    pass
+            if count == self.params.retries:
                 self.close(reconnect=True)
-                raise
+                raise ModbusIOException(
+                    f"ERROR: No response received after {self.params.retries} retries"
+                )
         return resp
 
     def callback_data(self, data: bytes, addr: tuple = None) -> int:
diff --git a/pymodbus/server/simulator/http_server.py b/pymodbus/server/simulator/http_server.py
--- a/pymodbus/server/simulator/http_server.py
+++ b/pymodbus/server/simulator/http_server.py
@@ -87,8 +87,8 @@ class ModbusSimulatorServer:
 
     :param modbus_server: Server name in json file (default: "server")
     :param modbus_device: Device name in json file (default: "client")
-    :param http_host: TCP host for HTTP (default: 8080)
-    :param http_port: TCP port for HTTP (default: "localhost")
+    :param http_host: TCP host for HTTP (default: "localhost")
+    :param http_port: TCP port for HTTP (default: 8080)
     :param json_file: setup file (default: "setup.json")
     :param custom_actions_module: python module with custom actions (default: none)
 
@@ -227,7 +227,9 @@ async def start_modbus_server(self, app):
         except Exception as exc:
             Log.error("Error starting modbus server, reason: {}", exc)
             raise exc
-        Log.info("Modbus server started")
+        Log.info(
+            "Modbus server started on {}", self.modbus_server.comm_params.source_address
+        )
 
     async def stop_modbus_server(self, app):
         """Stop modbus server."""
@@ -248,7 +250,7 @@ async def run_forever(self, only_start=False):
         except Exception as exc:
             Log.error("Error starting http server, reason: {}", exc)
             raise exc
-        Log.info("HTTP server started")
+        Log.info("HTTP server started on ({}:{})", self.http_host, self.http_port)
         if only_start:
             return
         while True:


Test patch: