Repository: datastax/astrapy (language: python)

Issue:
optional "client_name" additional property in clients to specialize user-agent?
to be prepended to user-agent if provided (with separating slash).
Very back-compatible.


Discussion / hints:
A design decision is in order here. The context is, a future LangChain will use a future astrapy and set this optional "client name", which goes all the way to the user agent.

LangChain's classes (as many other similar plugins) accept both the credentials to create their `AstraDB` instance, or a ready-made instance provided from outside. What should then the plugin do? (and how should astrapy's API be like?)

1. the plugin calls `astra_db.set_client_name("langchain")`?
2. the setting is at collection level, i.e. `astra_db_collection.set_client_name("langchain")`?
3. no properties, just an optional parameter to _each_ method that results in API calls?

Now,

- `3` is unwieldy to maintain
- `1` is not something the plugin has the right to do since the `astra_db` object can in principle be shared by other code (plus, once it spawns an AstraDBCollection it does not really "talk to it" anymore).
- `2` seems to be the right place. With some care, as the plugin code in Langchain (that we take for a blueprint of most plugins) also does occasional calls through the AstraDB class, viz. DDL.

So, even if going with `2`, if we want to properly characterize DDL calls with their "client_name" we need at least

* an additional param in one-off methods of AstraDB for DDL
* either a param in each collection method (maintenance-heavy) or a collection-level property that gets used in each request.

Choosing param for AstraDB and instance property for AstraDBCollection is ... ugly and asymmetrical.
Do we have enough information to make a decision on the design options shared above?
Well, this is mostly an internal issue of maintainability/flexibility, with little exposure to code using astrapy (such as the LangChain plugin) and virtually zero to end user, so we can go with what looks like a least-effort satisfactory choice and adjust later (though hopefully not).

Fix patch:
diff --git a/astrapy/api.py b/astrapy/api.py
index 12bb38f1..a21ab651 100644
--- a/astrapy/api.py
+++ b/astrapy/api.py
@@ -1,14 +1,10 @@
 import logging
 import httpx
-from typing import Any, Dict, Optional, TypeVar, cast
+from typing import Any, Dict, Optional, cast
 
 from astrapy.types import API_RESPONSE
 from astrapy.utils import amake_request, make_request
 
-T = TypeVar("T", bound="APIRequestHandler")
-AT = TypeVar("AT", bound="AsyncAPIRequestHandler")
-
-
 logger = logging.getLogger(__name__)
 
 
@@ -22,129 +18,156 @@ def __repr__(self) -> str:
         return f"{self.response}"
 
 
-class APIRequestHandler:
-    def __init__(
-        self: T,
-        client: httpx.Client,
-        base_url: str,
-        auth_header: str,
-        token: str,
-        method: str,
-        json_data: Optional[Dict[str, Any]],
-        url_params: Optional[Dict[str, Any]],
-        path: Optional[str] = None,
-        skip_error_check: bool = False,
-    ) -> None:
-        self.client = client
-        self.base_url = base_url
-        self.auth_header = auth_header
-        self.token = token
-        self.method = method
-        self.path = path
-        self.json_data = json_data
-        self.url_params = url_params
-        self.skip_error_check = skip_error_check
-
-    def raw_request(self: T) -> httpx.Response:
-        return make_request(
-            client=self.client,
-            base_url=self.base_url,
-            auth_header=self.auth_header,
-            token=self.token,
-            method=self.method,
-            path=self.path,
-            json_data=self.json_data,
-            url_params=self.url_params,
-        )
-
-    def request(self: T) -> API_RESPONSE:
-        # Make the raw request to the API
-        self.response = self.raw_request()
-
-        # If the response was not successful (non-success error code) raise an error directly
-        self.response.raise_for_status()
-
-        # Otherwise, process the successful response
-        return self._process_response()
-
-    def _process_response(self: T) -> API_RESPONSE:
-        # In case of other successful responses, parse the JSON body.
-        try:
-            # Cast the response to the expected type.
-            response_body: API_RESPONSE = cast(API_RESPONSE, self.response.json())
-
-            # If the API produced an error, warn and raise it as an Exception
-            if "errors" in response_body and not self.skip_error_check:
-                logger.debug(response_body["errors"])
-
-                raise APIRequestError(self.response)
-
-            # Otherwise, set the response body
-            return response_body
-        except ValueError:
-            # Handle cases where json() parsing fails (e.g., empty body)
-            raise APIRequestError(self.response)
-
-
-class AsyncAPIRequestHandler:
-    def __init__(
-        self: AT,
-        client: httpx.AsyncClient,
-        base_url: str,
-        auth_header: str,
-        token: str,
-        method: str,
-        json_data: Optional[Dict[str, Any]],
-        url_params: Optional[Dict[str, Any]],
-        path: Optional[str] = None,
-        skip_error_check: bool = False,
-    ) -> None:
-        self.client = client
-        self.base_url = base_url
-        self.auth_header = auth_header
-        self.token = token
-        self.method = method
-        self.path = path
-        self.json_data = json_data
-        self.url_params = url_params
-        self.skip_error_check = skip_error_check
-
-    async def raw_request(self: AT) -> httpx.Response:
-        return await amake_request(
-            client=self.client,
-            base_url=self.base_url,
-            auth_header=self.auth_header,
-            token=self.token,
-            method=self.method,
-            path=self.path,
-            json_data=self.json_data,
-            url_params=self.url_params,
-        )
-
-    async def request(self: AT) -> API_RESPONSE:
-        # Make the raw request to the API
-        self.response = await self.raw_request()
-
-        # If the response was not successful (non-success error code) raise an error directly
-        self.response.raise_for_status()
-
-        # Otherwise, process the successful response
-        return await self._process_response()
-
-    async def _process_response(self: AT) -> API_RESPONSE:
-        # In case of other successful responses, parse the JSON body.
-        try:
-            # Cast the response to the expected type.
-            response_body: API_RESPONSE = cast(API_RESPONSE, self.response.json())
-
-            # If the API produced an error, warn and return the API request error class
-            if "errors" in response_body and not self.skip_error_check:
-                logger.debug(response_body["errors"])
-
-                raise APIRequestError(self.response)
-
-            # Otherwise, set the response body
-            return response_body
-        except ValueError:
-            # Handle cases where json() parsing fails (e.g., empty body)
-            raise APIRequestError(self.response)
+def raw_api_request(
+    client: httpx.Client,
+    base_url: str,
+    auth_header: str,
+    token: str,
+    method: str,
+    json_data: Optional[Dict[str, Any]],
+    url_params: Optional[Dict[str, Any]],
+    path: Optional[str],
+    caller_name: Optional[str],
+    caller_version: Optional[str],
+) -> httpx.Response:
+    return make_request(
+        client=client,
+        base_url=base_url,
+        auth_header=auth_header,
+        token=token,
+        method=method,
+        json_data=json_data,
+        url_params=url_params,
+        path=path,
+        caller_name=caller_name,
+        caller_version=caller_version,
+    )
+
+
+def process_raw_api_response(
+    raw_response: httpx.Response, skip_error_check: bool
+) -> API_RESPONSE:
+    # In case of other successful responses, parse the JSON body.
+    try:
+        # Cast the response to the expected type.
+        response_body: API_RESPONSE = cast(API_RESPONSE, raw_response.json())
+
+        # If the API produced an error, warn and raise it as an Exception
+        if "errors" in response_body and not skip_error_check:
+            logger.debug(response_body["errors"])
+
+            raise APIRequestError(raw_response)
+
+        # Otherwise, set the response body
+        return response_body
+    except ValueError:
+        # Handle cases where json() parsing fails (e.g., empty body)
+        raise APIRequestError(raw_response)
+
+
+def api_request(
+    client: httpx.Client,
+    base_url: str,
+    auth_header: str,
+    token: str,
+    method: str,
+    json_data: Optional[Dict[str, Any]],
+    url_params: Optional[Dict[str, Any]],
+    path: Optional[str],
+    skip_error_check: bool,
+    caller_name: Optional[str],
+    caller_version: Optional[str],
+) -> API_RESPONSE:
+    raw_response = raw_api_request(
+        client=client,
+        base_url=base_url,
+        auth_header=auth_header,
+        token=token,
+        method=method,
+        json_data=json_data,
+        url_params=url_params,
+        path=path,
+        caller_name=caller_name,
+        caller_version=caller_version,
+    )
+    raw_response.raise_for_status()
+    return process_raw_api_response(raw_response, skip_error_check=skip_error_check)
+
+
+###
+async def async_raw_api_request(
+    client: httpx.AsyncClient,
+    base_url: str,
+    auth_header: str,
+    token: str,
+    method: str,
+    json_data: Optional[Dict[str, Any]],
+    url_params: Optional[Dict[str, Any]],
+    path: Optional[str],
+    caller_name: Optional[str],
+    caller_version: Optional[str],
+) -> httpx.Response:
+    return await amake_request(
+        client=client,
+        base_url=base_url,
+        auth_header=auth_header,
+        token=token,
+        method=method,
+        json_data=json_data,
+        url_params=url_params,
+        path=path,
+        caller_name=caller_name,
+        caller_version=caller_version,
+    )
+
+
+async def async_process_raw_api_response(
+    raw_response: httpx.Response, skip_error_check: bool
+) -> API_RESPONSE:
+    # In case of other successful responses, parse the JSON body.
+    try:
+        # Cast the response to the expected type.
+        response_body: API_RESPONSE = cast(API_RESPONSE, raw_response.json())
+
+        # If the API produced an error, warn and return the API request error class
+        if "errors" in response_body and not skip_error_check:
+            logger.debug(response_body["errors"])
+
+            raise APIRequestError(raw_response)
+
+        # Otherwise, set the response body
+        return response_body
+    except ValueError:
+        # Handle cases where json() parsing fails (e.g., empty body)
+        raise APIRequestError(raw_response)
+
+
+async def async_api_request(
+    client: httpx.AsyncClient,
+    base_url: str,
+    auth_header: str,
+    token: str,
+    method: str,
+    json_data: Optional[Dict[str, Any]],
+    url_params: Optional[Dict[str, Any]],
+    path: Optional[str],
+    skip_error_check: bool,
+    caller_name: Optional[str],
+    caller_version: Optional[str],
+) -> API_RESPONSE:
+    raw_response = await async_raw_api_request(
+        client=client,
+        base_url=base_url,
+        auth_header=auth_header,
+        token=token,
+        method=method,
+        json_data=json_data,
+        url_params=url_params,
+        path=path,
+        caller_name=caller_name,
+        caller_version=caller_version,
+    )
+    raw_response.raise_for_status()
+    return await async_process_raw_api_response(
+        raw_response, skip_error_check=skip_error_check
+    )
diff --git a/astrapy/db.py b/astrapy/db.py
index 6e2a082b..f4698b5e 100644
--- a/astrapy/db.py
+++ b/astrapy/db.py
@@ -39,7 +39,7 @@
 )
 
 from astrapy import __version__
-from astrapy.api import AsyncAPIRequestHandler, APIRequestHandler
+from astrapy.api import api_request, async_api_request
 from astrapy.defaults import (
     DEFAULT_AUTH_HEADER,
     DEFAULT_JSON_API_PATH,
@@ -115,24 +115,21 @@ def _request(
         json_data: Optional[Dict[str, Any]] = None,
         url_params: Optional[Dict[str, Any]] = None,
         skip_error_check: bool = False,
-        **kwargs: Any,
     ) -> API_RESPONSE:
-        request_handler = APIRequestHandler(
+        direct_response = api_request(
             client=self.client,
             base_url=self.astra_db.base_url,
             auth_header=DEFAULT_AUTH_HEADER,
             token=self.astra_db.token,
             method=method,
-            path=path,
             json_data=normalize_for_api(json_data),
             url_params=url_params,
+            path=path,
             skip_error_check=skip_error_check,
-            **kwargs,
+            caller_name=None,
+            caller_version=None,
         )
-
-        direct_response = request_handler.request()
         response = restore_from_api(direct_response)
-
         return response
 
     def _get(
@@ -1034,21 +1031,20 @@ async def _request(
         skip_error_check: bool = False,
         **kwargs: Any,
     ) -> API_RESPONSE:
-        arequest_handler = AsyncAPIRequestHandler(
+        adirect_response = await async_api_request(
             client=self.client,
             base_url=self.astra_db.base_url,
             auth_header=DEFAULT_AUTH_HEADER,
             token=self.astra_db.token,
             method=method,
-            path=path,
             json_data=normalize_for_api(json_data),
             url_params=url_params,
+            path=path,
             skip_error_check=skip_error_check,
+            caller_name=None,
+            caller_version=None,
         )
-
-        direct_response = await arequest_handler.request()
-        response = restore_from_api(direct_response)
-
+        response = restore_from_api(adirect_response)
         return response
 
     async def _get(
@@ -1915,24 +1911,21 @@ def _request(
         json_data: Optional[Dict[str, Any]] = None,
         url_params: Optional[Dict[str, Any]] = None,
         skip_error_check: bool = False,
-        **kwargs: Any,
     ) -> API_RESPONSE:
-        request_handler = APIRequestHandler(
+        direct_response = api_request(
             client=self.client,
             base_url=self.base_url,
             auth_header=DEFAULT_AUTH_HEADER,
             token=self.token,
             method=method,
-            path=path,
             json_data=normalize_for_api(json_data),
             url_params=url_params,
+            path=path,
             skip_error_check=skip_error_check,
-            **kwargs,
+            caller_name=None,
+            caller_version=None,
         )
-
-        direct_response = request_handler.request()
         response = restore_from_api(direct_response)
-
         return response
 
     def collection(self, collection_name: str) -> AstraDBCollection:
@@ -2159,24 +2152,21 @@ async def _request(
         json_data: Optional[Dict[str, Any]] = None,
         url_params: Optional[Dict[str, Any]] = None,
         skip_error_check: bool = False,
-        **kwargs: Any,
     ) -> API_RESPONSE:
-        request_handler = AsyncAPIRequestHandler(
+        adirect_response = await async_api_request(
             client=self.client,
             base_url=self.base_url,
             auth_header=DEFAULT_AUTH_HEADER,
             token=self.token,
             method=method,
-            path=path,
             json_data=normalize_for_api(json_data),
             url_params=url_params,
+            path=path,
             skip_error_check=skip_error_check,
-            **kwargs,
+            caller_name=None,
+            caller_version=None,
         )
-
-        direct_response = await request_handler.request()
-        response = restore_from_api(direct_response)
-
+        response = restore_from_api(adirect_response)
         return response
 
     async def collection(self, collection_name: str) -> AsyncAstraDBCollection:
diff --git a/astrapy/ops.py b/astrapy/ops.py
index 15c319cd..4c9ea5ff 100644
--- a/astrapy/ops.py
+++ b/astrapy/ops.py
@@ -16,7 +16,7 @@
 from typing import Any, cast, Dict, Optional
 
 import httpx
-from astrapy.api import APIRequestHandler
+from astrapy.api import api_request, raw_api_request
 
 from astrapy.utils import http_methods
 from astrapy.defaults import (
@@ -57,20 +57,19 @@ def _ops_request(
     ) -> httpx.Response:
         _options = {} if options is None else options
 
-        request_handler = APIRequestHandler(
+        raw_response = raw_api_request(
             client=self.client,
             base_url=self.base_url,
             auth_header=DEFAULT_DEV_OPS_AUTH_HEADER,
             token=self.token,
             method=method,
-            path=path,
             json_data=json_data,
             url_params=_options,
+            path=path,
+            caller_name=None,
+            caller_version=None,
         )
-
-        response = request_handler.raw_request()
-
-        return response
+        return raw_response
 
     def _json_ops_request(
         self,
@@ -81,19 +80,19 @@ def _json_ops_request(
     ) -> OPS_API_RESPONSE:
         _options = {} if options is None else options
 
-        request_handler = APIRequestHandler(
+        response = api_request(
             client=self.client,
             base_url=self.base_url,
             auth_header="Authorization",
             token=self.token,
             method=method,
-            path=path,
             json_data=json_data,
             url_params=_options,
+            path=path,
+            skip_error_check=False,
+            caller_name=None,
+            caller_version=None,
         )
-
-        response = request_handler.request()
-
         return response
 
     def get_databases(
diff --git a/astrapy/utils.py b/astrapy/utils.py
index 2a88de58..b3fba700 100644
--- a/astrapy/utils.py
+++ b/astrapy/utils.py
@@ -64,15 +64,29 @@ def log_request_response(
     logger.debug(f"Response content: {r.text}")
 
 
+def compose_user_agent(
+    caller_name: Optional[str], caller_version: Optional[str]
+) -> str:
+    if caller_name:
+        if caller_version:
+            return f"{caller_name}/{caller_version} {package_name}/{__version__}"
+        else:
+            return f"{caller_name} {package_name}/{__version__}"
+    else:
+        return f"{package_name}/{__version__}"
+
+
 def make_request(
     client: httpx.Client,
     base_url: str,
     auth_header: str,
     token: str,
-    method: str = http_methods.POST,
-    path: Optional[str] = None,
-    json_data: Optional[Dict[str, Any]] = None,
-    url_params: Optional[Dict[str, Any]] = None,
+    method: str,
+    json_data: Optional[Dict[str, Any]],
+    url_params: Optional[Dict[str, Any]],
+    path: Optional[str],
+    caller_name: Optional[str],
+    caller_version: Optional[str],
 ) -> httpx.Response:
     """
     Make an HTTP request to a specified URL.
@@ -96,7 +110,10 @@ def make_request(
         params=url_params,
         json=json_data,
         timeout=DEFAULT_TIMEOUT,
-        headers={auth_header: token, "User-Agent": f"{package_name}/{__version__}"},
+        headers={
+            auth_header: token,
+            "User-Agent": compose_user_agent(caller_name, caller_version),
+        },
     )
 
     log_request_response(r, json_data)
@@ -109,10 +126,12 @@ async def amake_request(
     base_url: str,
     auth_header: str,
     token: str,
-    method: str = http_methods.POST,
-    path: Optional[str] = None,
-    json_data: Optional[Dict[str, Any]] = None,
-    url_params: Optional[Dict[str, Any]] = None,
+    method: str,
+    path: Optional[str],
+    json_data: Optional[Dict[str, Any]],
+    url_params: Optional[Dict[str, Any]],
+    caller_name: Optional[str],
+    caller_version: Optional[str],
 ) -> httpx.Response:
     """
     Make an HTTP request to a specified URL.
@@ -136,7 +155,10 @@ async def amake_request(
         params=url_params,
         json=json_data,
         timeout=DEFAULT_TIMEOUT,
-        headers={auth_header: token, "User-Agent": f"{package_name}/{__version__}"},
+        headers={
+            auth_header: token,
+            "User-Agent": compose_user_agent(caller_name, caller_version),
+        },
     )
 
     log_request_response(r, json_data)
diff --git a/poetry.lock b/poetry.lock
index 6d1d203d..3603e4e6 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand.
 
 [[package]]
 name = "anyio"
@@ -134,13 +134,13 @@ requests = ">=2"
 
 [[package]]
 name = "certifi"
-version = "2023.11.17"
+version = "2024.2.2"
 description = "Python package for providing Mozilla's CA Bundle."
 optional = false
 python-versions = ">=3.6"
 files = [
-    {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"},
-    {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"},
+    {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"},
+    {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"},
 ]
 
 [[package]]
@@ -560,6 +560,75 @@ files = [
     {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
 ]
 
+[[package]]
+name = "markupsafe"
+version = "2.1.5"
+description = "Safely add untrusted strings to HTML/XML markup."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"},
+    {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"},
+]
+
 [[package]]
 name = "mypy"
 version = "1.7.1"
@@ -797,6 +866,20 @@ pytest = ">=4.6"
 [package.extras]
 testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"]
 
+[[package]]
+name = "pytest-httpserver"
+version = "1.0.8"
+description = "pytest-httpserver is a httpserver for pytest"
+optional = false
+python-versions = ">=3.8,<4.0"
+files = [
+    {file = "pytest_httpserver-1.0.8-py3-none-any.whl", hash = "sha256:24cd3d9f6a0b927c7bfc400d0b3fda7442721b8267ce29942bf307b190f0bb09"},
+    {file = "pytest_httpserver-1.0.8.tar.gz", hash = "sha256:e052f69bc8a9073db02484681e8e47004dd1fb3763b0ae833bd899e5895c559a"},
+]
+
+[package.dependencies]
+Werkzeug = ">=2.0.0"
+
 [[package]]
 name = "pytest-testdox"
 version = "3.1.0"
@@ -851,7 +934,6 @@ files = [
     {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
     {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
     {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
-    {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
     {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
     {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
     {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
@@ -859,15 +941,8 @@ files = [
     {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
     {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
     {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
-    {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
     {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
     {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
-    {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
-    {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
-    {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
-    {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
-    {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
-    {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
     {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
     {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
     {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
@@ -884,7 +959,6 @@ files = [
     {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
     {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
     {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
-    {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
     {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
     {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
     {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
@@ -892,7 +966,6 @@ files = [
     {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
     {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
     {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
-    {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
     {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
     {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
     {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
@@ -1064,7 +1137,24 @@ platformdirs = ">=3.9.1,<5"
 docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
 test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
 
+[[package]]
+name = "werkzeug"
+version = "3.0.1"
+description = "The comprehensive WSGI web application library."
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "werkzeug-3.0.1-py3-none-any.whl", hash = "sha256:90a285dc0e42ad56b34e696398b8122ee4c681833fb35b8334a095d82c56da10"},
+    {file = "werkzeug-3.0.1.tar.gz", hash = "sha256:507e811ecea72b18a404947aded4b3390e1db8f826b494d76550ef45bb3b1dcc"},
+]
+
+[package.dependencies]
+MarkupSafe = ">=2.1.1"
+
+[package.extras]
+watchdog = ["watchdog (>=2.3)"]
+
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.8.0"
-content-hash = "184bec57362735bf72830fa3583732d9dc7a54c7338732b99e8a17d805e1551c"
+content-hash = "54aa7f55ddcccc2400537d1245e24541578a0520c6f4b9b945a47e771e446bd7"
diff --git a/pyproject.toml b/pyproject.toml
index b6353f1a..62b1b044 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,6 +42,7 @@ pytest-cov = "~4.1.0"
 pytest-testdox = "~3.1.0"
 pytest = "~7.4.3"
 python-dotenv = "~1.0.0"
+pytest-httpserver = "~1.0.8"
 ruff = "~0.1.5"
 types-toml = "^0.10.8.7"
 


Test patch:
diff --git a/tests/astrapy/test_user_agent.py b/tests/astrapy/test_user_agent.py
new file mode 100644
index 00000000..ddea3ee8
--- /dev/null
+++ b/tests/astrapy/test_user_agent.py
@@ -0,0 +1,69 @@
+# Copyright DataStax, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Tests for the User-Agent customization logic
+"""
+
+import logging
+import pytest
+from pytest_httpserver import HTTPServer
+
+from astrapy import __version__
+from astrapy.utils import compose_user_agent, package_name
+from astrapy.db import AstraDB
+
+
+logger = logging.getLogger(__name__)
+
+
+@pytest.mark.describe("compose_user_agent")
+def test_compose_user_agent() -> None:
+    assert (
+        compose_user_agent(caller_name=None, caller_version=None)
+        == f"{package_name}/{__version__}"
+    )
+    assert (
+        compose_user_agent(caller_name="N", caller_version=None)
+        == f"N {package_name}/{__version__}"
+    )
+    assert (
+        compose_user_agent(caller_name=None, caller_version="V")
+        == f"{package_name}/{__version__}"
+    )
+    assert (
+        compose_user_agent(caller_name="N", caller_version="V")
+        == f"N/V {package_name}/{__version__}"
+    )
+
+
+@pytest.mark.describe("test user-agent in the standard way for AstraDB")
+def test_useragent_astradb_standard(httpserver: HTTPServer) -> None:
+    root_endpoint = httpserver.url_for("/")
+    my_db = AstraDB(
+        token="token",
+        api_endpoint=root_endpoint,
+        namespace="ns",
+    )
+    expected_url = f"{my_db.base_path}"
+
+    httpserver.expect_request(
+        expected_url,
+        method="POST",
+        headers={
+            "user-agent": compose_user_agent(caller_name=None, caller_version=None)
+        },
+    ).respond_with_data("{}")
+
+    my_db.get_collections()