Repository: fractal-analytics-platform/fractal-client (language: python)

Issue:
More general error when no token obtained?
(not urgent)

While at FMI, we need to set proxies to install tasks from pip. If I set the proxies, I can't reach the Fractal server anymore though with the CLI client. That's a pity, but not our task to solve atm.

What is a bit misleading is the error I run into (with any fractal command, i.e. fractal user whoami):
`Error: could not obtain token. Is the user registered?`

I'm not exactly sure where it fails, but it never seems to reach the server (does not show up in any logs). The error message suggests there is an issue with the user registration, but actually the server just is not reachable because of the proxy.
Improve handling of "All connection attempts failed" error
Partly related to #518 

If there is no fractal-server instance, or it's not reachable for whatever reason, the client behaves as in
```console
$ fractal user whoami
All connection attempts failed
```
which is not very informative.

We should improve the `ConnectError`-catching line line in `client.py`

---

The detailed reproducer is
```python
import asyncio
import httpx


async def main():
    async with httpx.AsyncClient() as client:
        await client.post("http://localhost:12345", json={})


if __name__ == "__main__":
    asyncio.run(main())
```
which leads to
```python traceback
Traceback (most recent call last):
  File "/tmp/venv/lib/python3.10/site-packages/anyio/_core/_sockets.py", line 170, in try_connect
    stream = await asynclib.connect_tcp(remote_host, remote_port, local_address)
  File "/tmp/venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 2258, in connect_tcp
    await get_running_loop().create_connection(
  File "/usr/lib/python3.10/asyncio/base_events.py", line 1076, in create_connection
    raise exceptions[0]
  File "/usr/lib/python3.10/asyncio/base_events.py", line 1060, in create_connection
    sock = await self._connect_sock(
  File "/usr/lib/python3.10/asyncio/base_events.py", line 969, in _connect_sock
    await self.sock_connect(sock, address)
  File "/usr/lib/python3.10/asyncio/selector_events.py", line 501, in sock_connect
    return await fut
  File "/usr/lib/python3.10/asyncio/selector_events.py", line 541, in _sock_connect_cb
    raise OSError(err, f'Connect call failed {address}')
ConnectionRefusedError: [Errno 111] Connect call failed ('127.0.0.1', 12345)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_exceptions.py", line 10, in map_exceptions
    yield
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_backends/anyio.py", line 114, in connect_tcp
    stream: anyio.abc.ByteStream = await anyio.connect_tcp(
  File "/tmp/venv/lib/python3.10/site-packages/anyio/_core/_sockets.py", line 232, in connect_tcp
    raise OSError("All connection attempts failed") from cause
OSError: All connection attempts failed

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_transports/default.py", line 67, in map_httpcore_exceptions
    yield
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_transports/default.py", line 371, in handle_async_request
    resp = await self._pool.handle_async_request(req)
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_async/connection_pool.py", line 268, in handle_async_request
    raise exc
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_async/connection_pool.py", line 251, in handle_async_request
    response = await connection.handle_async_request(request)
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_async/connection.py", line 99, in handle_async_request
    raise exc
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_async/connection.py", line 76, in handle_async_request
    stream = await self._connect(request)
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_async/connection.py", line 124, in _connect
    stream = await self._network_backend.connect_tcp(**kwargs)
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_backends/auto.py", line 30, in connect_tcp
    return await self._backend.connect_tcp(
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_backends/anyio.py", line 112, in connect_tcp
    with map_exceptions(exc_map):
  File "/usr/lib/python3.10/contextlib.py", line 153, in __exit__
    self.gen.throw(typ, value, traceback)
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_exceptions.py", line 14, in map_exceptions
    raise to_exc(exc) from exc
httpcore.ConnectError: All connection attempts failed

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/tmp/a.py", line 11, in <module>
    asyncio.run(main())
  File "/usr/lib/python3.10/asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "/usr/lib/python3.10/asyncio/base_events.py", line 649, in run_until_complete
    return future.result()
  File "/tmp/a.py", line 7, in main
    await client.post("http://localhost:12345", json={})
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_client.py", line 1877, in post
    return await self.request(
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_client.py", line 1559, in request
    return await self.send(request, auth=auth, follow_redirects=follow_redirects)
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_client.py", line 1646, in send
    response = await self._send_handling_auth(
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_client.py", line 1674, in _send_handling_auth
    response = await self._send_handling_redirects(
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_client.py", line 1711, in _send_handling_redirects
    response = await self._send_single_request(request)
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_client.py", line 1748, in _send_single_request
    response = await transport.handle_async_request(request)
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_transports/default.py", line 370, in handle_async_request
    with map_httpcore_exceptions():
  File "/usr/lib/python3.10/contextlib.py", line 153, in __exit__
    self.gen.throw(typ, value, traceback)
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_transports/default.py", line 84, in map_httpcore_exceptions
    raise mapped_exc(message) from exc
httpx.ConnectError: All connection attempts failed
```
Improve handling of "All connection attempts failed" error
Partly related to #518 

If there is no fractal-server instance, or it's not reachable for whatever reason, the client behaves as in
```console
$ fractal user whoami
All connection attempts failed
```
which is not very informative.

We should improve the `ConnectError`-catching line line in `client.py`

---

The detailed reproducer is
```python
import asyncio
import httpx


async def main():
    async with httpx.AsyncClient() as client:
        await client.post("http://localhost:12345", json={})


if __name__ == "__main__":
    asyncio.run(main())
```
which leads to
```python traceback
Traceback (most recent call last):
  File "/tmp/venv/lib/python3.10/site-packages/anyio/_core/_sockets.py", line 170, in try_connect
    stream = await asynclib.connect_tcp(remote_host, remote_port, local_address)
  File "/tmp/venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 2258, in connect_tcp
    await get_running_loop().create_connection(
  File "/usr/lib/python3.10/asyncio/base_events.py", line 1076, in create_connection
    raise exceptions[0]
  File "/usr/lib/python3.10/asyncio/base_events.py", line 1060, in create_connection
    sock = await self._connect_sock(
  File "/usr/lib/python3.10/asyncio/base_events.py", line 969, in _connect_sock
    await self.sock_connect(sock, address)
  File "/usr/lib/python3.10/asyncio/selector_events.py", line 501, in sock_connect
    return await fut
  File "/usr/lib/python3.10/asyncio/selector_events.py", line 541, in _sock_connect_cb
    raise OSError(err, f'Connect call failed {address}')
ConnectionRefusedError: [Errno 111] Connect call failed ('127.0.0.1', 12345)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_exceptions.py", line 10, in map_exceptions
    yield
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_backends/anyio.py", line 114, in connect_tcp
    stream: anyio.abc.ByteStream = await anyio.connect_tcp(
  File "/tmp/venv/lib/python3.10/site-packages/anyio/_core/_sockets.py", line 232, in connect_tcp
    raise OSError("All connection attempts failed") from cause
OSError: All connection attempts failed

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_transports/default.py", line 67, in map_httpcore_exceptions
    yield
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_transports/default.py", line 371, in handle_async_request
    resp = await self._pool.handle_async_request(req)
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_async/connection_pool.py", line 268, in handle_async_request
    raise exc
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_async/connection_pool.py", line 251, in handle_async_request
    response = await connection.handle_async_request(request)
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_async/connection.py", line 99, in handle_async_request
    raise exc
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_async/connection.py", line 76, in handle_async_request
    stream = await self._connect(request)
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_async/connection.py", line 124, in _connect
    stream = await self._network_backend.connect_tcp(**kwargs)
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_backends/auto.py", line 30, in connect_tcp
    return await self._backend.connect_tcp(
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_backends/anyio.py", line 112, in connect_tcp
    with map_exceptions(exc_map):
  File "/usr/lib/python3.10/contextlib.py", line 153, in __exit__
    self.gen.throw(typ, value, traceback)
  File "/tmp/venv/lib/python3.10/site-packages/httpcore/_exceptions.py", line 14, in map_exceptions
    raise to_exc(exc) from exc
httpcore.ConnectError: All connection attempts failed

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/tmp/a.py", line 11, in <module>
    asyncio.run(main())
  File "/usr/lib/python3.10/asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "/usr/lib/python3.10/asyncio/base_events.py", line 649, in run_until_complete
    return future.result()
  File "/tmp/a.py", line 7, in main
    await client.post("http://localhost:12345", json={})
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_client.py", line 1877, in post
    return await self.request(
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_client.py", line 1559, in request
    return await self.send(request, auth=auth, follow_redirects=follow_redirects)
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_client.py", line 1646, in send
    response = await self._send_handling_auth(
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_client.py", line 1674, in _send_handling_auth
    response = await self._send_handling_redirects(
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_client.py", line 1711, in _send_handling_redirects
    response = await self._send_single_request(request)
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_client.py", line 1748, in _send_single_request
    response = await transport.handle_async_request(request)
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_transports/default.py", line 370, in handle_async_request
    with map_httpcore_exceptions():
  File "/usr/lib/python3.10/contextlib.py", line 153, in __exit__
    self.gen.throw(typ, value, traceback)
  File "/tmp/venv/lib/python3.10/site-packages/httpx/_transports/default.py", line 84, in map_httpcore_exceptions
    raise mapped_exc(message) from exc
httpx.ConnectError: All connection attempts failed
```


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cbd369c56..de83a62f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 
 # 1.4.3 (unreleased)
 
+* Improve handling of `AuthenticationError` and `ConnectionError`, and handle uncaught `Exception` (\#587).
 * Testing:
     * Adapt `job_factory` and tests to the presence of new `timestamp_created` attributes in `fractal-server` (\#589).
 * Documentation:
diff --git a/fractal_client/authclient.py b/fractal_client/authclient.py
index f5fda010a..4aad48ff2 100644
--- a/fractal_client/authclient.py
+++ b/fractal_client/authclient.py
@@ -1,4 +1,3 @@
-from json import JSONDecodeError
 from pathlib import Path
 
 import jwt
@@ -38,13 +37,11 @@ async def _get_fresh_token(self):
             f"{settings.FRACTAL_SERVER}/auth/token/login/", data=data
         )
         if res.status_code != 200:
-            try:
-                data = res.json()
-            except JSONDecodeError:
-                data = ""
+            data = res.text
             raise AuthenticationError(
-                "Error: could not obtain token. Is the user registered?\n"
-                f"{data}\n"
+                f"Error at {res.request.url}.\n"
+                f"Status code: {res.status_code}.\n"
+                f"Response data: {data}.\n"
             )
         raw_token = res.json()
         self.token = raw_token["access_token"]
diff --git a/fractal_client/client.py b/fractal_client/client.py
index 0d41ad828..9a98407eb 100644
--- a/fractal_client/client.py
+++ b/fractal_client/client.py
@@ -109,7 +109,16 @@ async def handle(cli_args: List[str] = argv):
     except AuthenticationError as e:
         return PrintInterface(retcode=1, data=e.args[0])
     except ConnectError as e:
-        return PrintInterface(retcode=1, data=e.args[0])
+        return PrintInterface(
+            retcode=1,
+            data=(
+                f"ConnectError at {e.request.url}\n"
+                f"Original error: '{e.args[0]}'\n"
+                f"Hint: is {settings.FRACTAL_SERVER} alive?"
+            ),
+        )
+    except Exception as e:
+        return PrintInterface(retcode=1, data=str(e))
 
     return interface
 
diff --git a/fractal_client/cmd/_user.py b/fractal_client/cmd/_user.py
index 437fafb8d..5c130a00e 100644
--- a/fractal_client/cmd/_user.py
+++ b/fractal_client/cmd/_user.py
@@ -124,7 +124,6 @@ async def user_whoami(
 ) -> Union[RichJsonInterface, PrintInterface]:
     res = await client.get(f"{settings.FRACTAL_SERVER}/auth/current-user/")
     user = check_response(res, expected_status_code=200)
-
     if batch:
         return PrintInterface(retcode=0, data=user["id"])
     else:


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 4d7ecb832..90e1af840 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -100,6 +100,7 @@ def _override_settings(
         FRACTAL_CACHE_PATH=str(tmp_path),
         FRACTAL_USER=None,
         FRACTAL_PASSWORD=None,
+        FRACTAL_SERVER=None,
     ):
         monkeypatch.setattr(
             fractal_client.config.settings,
@@ -116,6 +117,12 @@ def _override_settings(
             "FRACTAL_PASSWORD",
             FRACTAL_PASSWORD,
         )
+        if FRACTAL_SERVER is not None:
+            monkeypatch.setattr(
+                fractal_client.config.settings,
+                "FRACTAL_SERVER",
+                FRACTAL_SERVER,
+            )
 
     return _override_settings
 
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 43b1dd970..1f9b4856b 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -1,6 +1,7 @@
 from os import environ
 
 import pytest
+from devtools import debug
 
 from fractal_client.authclient import AuthenticationError
 from fractal_client.authclient import AuthToken
@@ -27,6 +28,7 @@ async def test_auth_fail(client):
     WHEN fetching a token
     THEN authentication error is raised
     """
-    with pytest.raises(AuthenticationError):
+    with pytest.raises(AuthenticationError) as err:
         auth = AuthToken(client, username="foo", password="bar")
         await auth()
+    debug(err.value)
diff --git a/tests/test_client.py b/tests/test_client.py
index c28070002..bd34b7873 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -7,7 +7,6 @@
 
 from fractal_client import __VERSION__
 from fractal_client.client import handle
-from fractal_client.client import MissingCredentialsError
 
 
 DEFAULT_TEST_EMAIL = environ["FRACTAL_USER"]
@@ -74,11 +73,21 @@ async def test_missing_credentials(override_settings):
     # Remove credentials from settings
     override_settings(FRACTAL_USER=None, FRACTAL_PASSWORD=None)
 
-    with pytest.raises(MissingCredentialsError) as e:
-        await handle(shlex.split("fractal user whoami"))
-    debug(e.value)
-    debug(e.value.args[0])
-    assert "FRACTAL_USER" in e.value.args[0]
+    res = await handle(shlex.split("fractal user whoami"))
+    debug(res.data)
+    assert res.retcode == 1
+
+
+async def test_connecterror(client, override_settings):
+    override_settings(
+        FRACTAL_USER="admin@fractal.xy",
+        FRACTAL_PASSWORD="1234",
+        FRACTAL_SERVER="http://localhost:12345",
+    )
+    res = await handle(shlex.split("fractal user whoami"))
+    debug(res.data)
+    assert "ConnectError" in res.data
+    assert "Hint: is http://localhost:12345 alive?" in res.data
 
 
 async def test_argparse_abbreviation(invoke_as_superuser):
diff --git a/tests/test_task.py b/tests/test_task.py
index 27bfefa30..fa82cce62 100644
--- a/tests/test_task.py
+++ b/tests/test_task.py
@@ -173,8 +173,9 @@ async def test_task_new(register_user, invoke, tmp_path):
     assert e.value.code == 1
 
     # create a new task passing not existing file
-    with pytest.raises(FileNotFoundError):
-        await invoke("task new _name _command _source --meta-file ./foo.pdf")
+    res = await invoke("task new _name _command _source --meta-file ./foo.pdf")
+    debug(res.data)
+    assert res.retcode == 1
 
 
 async def test_task_edit(
@@ -286,10 +287,11 @@ async def test_task_edit(
 
     # Test `file not found` errors
     NEW_FILE = "foo.json"
-    with pytest.raises(FileNotFoundError):
-        await invoke_as_superuser(
-            f"task edit --id {task_id} --meta-file {NEW_FILE}"
-        )
+    res = await invoke_as_superuser(
+        f"task edit --id {task_id} --meta-file {NEW_FILE}"
+    )
+    debug(res.data)
+    assert res.retcode == 1
 
     # Test successful edit of dictionary attributes
     meta_file = str(testdata_path / "task_edit_json/meta.json")