Repository: line/centraldogma-python (language: python)

Issue:
Support for automatic retry
It would be useful if a Central Dogma client has an option to automatically retry a failed request. If a Central Dogma cluster is rolling restarted, a client may receive the following exception. 
```
 centraldogma.exceptions.ShuttingDownException: {"exception":"com.linecorp.centraldogma.common.ShuttingDownException","message":""}
```

As other servers are healthy, a retry request may succeed.



Discussion / hints:
It's been a long time! Thanks for the nice suggestion. As workaround, you are able to use `configs` param.
- https://github.com/line/centraldogma-python/blob/main/centraldogma/dogma.py#L64
- https://github.com/line/centraldogma-python/blob/main/centraldogma/base_client.py#L29
- https://www.python-httpx.org/advanced/transports/#http-transport

```python
transport = httpx.HTTPTransport(retries=1)
Dogma("baseUrl", "token", transport=transport)
```

BTW, it is not a good design because of non-agnostic from httpx dependency. kind of syntactic sugar is supposed to be needed. I will handle it.

```python
Dogma("baseUrl", "token", retries=3)
```

Likewise, `max_connections` and `max_keepalive_connections` should be encapsulated.
- https://github.com/line/centraldogma-python/blob/main/tests/test_base_client.py#L31
Happy to see you again. 🙂

> transport = httpx.HTTPTransport(retries=1)

TIL. 📝 I didn't know that HTTPX provides retry out of the box. But it seems the feature is limited to the connection level errors.

> Requests will be retried the given number of times in case an httpx.ConnectError or an httpx.ConnectTimeout occurs, allowing smoother operation under flaky networks. If you need other forms of retry behaviors, such as handling read/write errors or **reacting to 503 Service Unavailable, consider general-purpose tools such as [tenacity](https://github.com/jd/tenacity)**.

`ShuttingDownException` will be returned with 503 Service Unstable status. That means we can
't leverage HTTPX retry to send subsequent requests for `ShuttingDownException`.


> it seems the feature is limited to the connection level errors.

Absolutely. To be clear, here are two issues (also expected two PRs). It seems that given number of retries can be used both for connection errors and server errors.

Fix patch:
diff --git a/centraldogma/base_client.py b/centraldogma/base_client.py
index 1729c71..86ce9d8 100644
--- a/centraldogma/base_client.py
+++ b/centraldogma/base_client.py
@@ -14,6 +14,7 @@
 from typing import Dict, Union, Callable, TypeVar, Optional
 
 from httpx import Client, HTTPTransport, Limits, Response
+from tenacity import stop_after_attempt, wait_exponential, Retrying
 
 from centraldogma.exceptions import to_exception
 
@@ -21,8 +22,6 @@
 
 
 class BaseClient:
-    PATH_PREFIX = "/api/v1"
-
     def __init__(
         self,
         base_url: str,
@@ -33,14 +32,21 @@ def __init__(
         max_keepalive_connections: int = 2,
         **configs,
     ):
+        assert retries >= 0, "retries must be greater than or equal to zero"
+        assert max_connections > 0, "max_connections must be greater than zero"
+        assert (
+            max_keepalive_connections > 0
+        ), "max_keepalive_connections must be greater than zero"
+
         base_url = base_url[:-1] if base_url[-1] == "/" else base_url
 
         for key in ["transport", "limits"]:
             if key in configs:
                 del configs[key]
 
+        self.retries = retries
         self.client = Client(
-            base_url=f"{base_url}{self.PATH_PREFIX}",
+            base_url=f"{base_url}/api/v1",
             http2=http2,
             transport=HTTPTransport(retries=retries),
             limits=Limits(
@@ -53,7 +59,6 @@ def __init__(
         self.headers = self._get_headers(token)
         self.patch_headers = self._get_patch_headers(token)
 
-    # TODO(@hexoul): Support automatic retry with `tenacity` even if an exception occurs.
     def request(
         self,
         method: str,
@@ -62,6 +67,25 @@ def request(
         **kwargs,
     ) -> Union[Response, T]:
         kwargs = self._set_request_headers(method, **kwargs)
+        retryer = Retrying(
+            stop=stop_after_attempt(self.retries + 1),
+            wait=wait_exponential(max=60),
+            reraise=True,
+        )
+        return retryer(self._request, method, path, handler, **kwargs)
+
+    def _set_request_headers(self, method: str, **kwargs) -> Dict:
+        default_headers = self.patch_headers if method == "patch" else self.headers
+        kwargs["headers"] = {**default_headers, **(kwargs.get("headers") or {})}
+        return kwargs
+
+    def _request(
+        self,
+        method: str,
+        path: str,
+        handler: Optional[Dict[int, Callable[[Response], T]]] = None,
+        **kwargs,
+    ):
         resp = self.client.request(method, path, **kwargs)
         if handler:
             converter = handler.get(resp.status_code)
@@ -71,11 +95,6 @@ def request(
                 raise to_exception(resp)
         return resp
 
-    def _set_request_headers(self, method: str, **kwargs) -> Dict:
-        default_headers = self.patch_headers if method == "patch" else self.headers
-        kwargs["headers"] = {**default_headers, **(kwargs.get("headers") or {})}
-        return kwargs
-
     @staticmethod
     def _get_headers(token: str) -> Dict:
         return {
diff --git a/requirements.txt b/requirements.txt
index 8b971fd..6168923 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,6 +3,7 @@ httpx[http2]
 marshmallow
 pydantic
 python-dateutil
+tenacity
 
 # Dev dependencies
 black
diff --git a/setup.py b/setup.py
index d4772e9..c7ee476 100644
--- a/setup.py
+++ b/setup.py
@@ -32,7 +32,7 @@ def get_install_requires():
 
 setup(
     name="centraldogma-python",
-    version="0.3.0",
+    version="0.4.0",
     description="Python client library for Central Dogma",
     long_description=get_long_description(),
     long_description_content_type="text/markdown",


Test patch:
diff --git a/tests/integration/test_content_service.py b/tests/integration/test_content_service.py
index 4ac46af..ec3216f 100644
--- a/tests/integration/test_content_service.py
+++ b/tests/integration/test_content_service.py
@@ -33,7 +33,7 @@
 )
 from centraldogma.query import Query
 
-dogma = Dogma()
+dogma = Dogma(retries=3)
 project_name = "TestProject"
 repo_name = "TestRepository"
 
diff --git a/tests/integration/test_project_service.py b/tests/integration/test_project_service.py
index f7c7136..2b0636d 100644
--- a/tests/integration/test_project_service.py
+++ b/tests/integration/test_project_service.py
@@ -21,7 +21,7 @@
     ProjectNotFoundException,
 )
 
-dogma = Dogma()
+dogma = Dogma(retries=3)
 project_name = "TestProject"
 
 
diff --git a/tests/integration/test_repository_service.py b/tests/integration/test_repository_service.py
index 3f32fc9..2ec2f70 100644
--- a/tests/integration/test_repository_service.py
+++ b/tests/integration/test_repository_service.py
@@ -20,7 +20,7 @@
 import pytest
 import os
 
-dogma = Dogma()
+dogma = Dogma(retries=3)
 project_name = "TestProject"
 repo_name = "TestRepository"
 
diff --git a/tests/integration/test_watcher.py b/tests/integration/test_watcher.py
index 7c1deaf..fdf3f95 100644
--- a/tests/integration/test_watcher.py
+++ b/tests/integration/test_watcher.py
@@ -25,7 +25,7 @@
 from centraldogma.query import Query
 from centraldogma.watcher import Watcher, Latest
 
-dogma = Dogma()
+dogma = Dogma(retries=3)
 project_name = "TestProject"
 repo_name = "TestRepository"
 
diff --git a/tests/test_base_client.py b/tests/test_base_client.py
index 48b0260..1a35922 100644
--- a/tests/test_base_client.py
+++ b/tests/test_base_client.py
@@ -15,10 +15,11 @@
 
 from centraldogma.exceptions import UnauthorizedException, NotFoundException
 from centraldogma.base_client import BaseClient
-from httpx import Response
+from httpx import ConnectError, NetworkError, Response
 import pytest
 
-client = BaseClient("http://baseurl", "token")
+base_url = "http://baseurl"
+client = BaseClient(base_url, "token", retries=0)
 
 configs = {
     "auth": None,
@@ -37,7 +38,7 @@
     "app": None,
     "trust_env": True,
 }
-client_with_configs = BaseClient("http://baseurl", "token", **configs)
+client_with_configs = BaseClient(base_url, "token", **configs)
 
 ok_handler = {HTTPStatus.OK: lambda resp: resp}
 
@@ -66,7 +67,7 @@ def test_set_request_headers():
 def test_request_with_configs(respx_mock):
     methods = ["get", "post", "put", "delete", "patch", "options"]
     for method in methods:
-        getattr(respx_mock, method)("http://baseurl/api/v1/path").mock(
+        getattr(respx_mock, method)(f"{base_url}/api/v1/path").mock(
             return_value=Response(200, text="success")
         )
         client.request(
@@ -82,7 +83,7 @@ def test_request_with_configs(respx_mock):
 
 
 def test_delete(respx_mock):
-    route = respx_mock.delete("http://baseurl/api/v1/path").mock(
+    route = respx_mock.delete(f"{base_url}/api/v1/path").mock(
         return_value=Response(200, text="success")
     )
     resp = client.request("delete", "/path", params={"a": "b"})
@@ -95,17 +96,18 @@ def test_delete(respx_mock):
 
 def test_delete_exception_authorization(respx_mock):
     with pytest.raises(UnauthorizedException):
-        respx_mock.delete("http://baseurl/api/v1/path").mock(return_value=Response(401))
+        respx_mock.delete(f"{base_url}/api/v1/path").mock(return_value=Response(401))
         client.request("delete", "/path", handler=ok_handler)
 
 
 def test_get(respx_mock):
-    route = respx_mock.get("http://baseurl/api/v1/path").mock(
+    route = respx_mock.get(f"{base_url}/api/v1/path").mock(
         return_value=Response(200, text="success")
     )
     resp = client.request("get", "/path", params={"a": "b"}, handler=ok_handler)
 
     assert route.called
+    assert route.call_count == 1
     assert resp.request.headers["Authorization"] == "bearer token"
     assert resp.request.headers["Content-Type"] == "application/json"
     assert resp.request.url.params.multi_items() == [("a", "b")]
@@ -113,18 +115,42 @@ def test_get(respx_mock):
 
 def test_get_exception_authorization(respx_mock):
     with pytest.raises(UnauthorizedException):
-        respx_mock.get("http://baseurl/api/v1/path").mock(return_value=Response(401))
+        respx_mock.get(f"{base_url}/api/v1/path").mock(return_value=Response(401))
         client.request("get", "/path", handler=ok_handler)
 
 
 def test_get_exception_not_found(respx_mock):
     with pytest.raises(NotFoundException):
-        respx_mock.get("http://baseurl/api/v1/path").mock(return_value=Response(404))
+        respx_mock.get(f"{base_url}/api/v1/path").mock(return_value=Response(404))
         client.request("get", "/path", handler=ok_handler)
 
 
+def test_get_with_retry_by_response(respx_mock):
+    route = respx_mock.get(f"{base_url}/api/v1/path").mock(
+        side_effect=[Response(503), Response(404), Response(200)],
+    )
+
+    retry_client = BaseClient(base_url, "token", retries=2)
+    retry_client.request("get", "/path", handler=ok_handler)
+
+    assert route.called
+    assert route.call_count == 3
+
+
+def test_get_with_retry_by_client(respx_mock):
+    route = respx_mock.get(f"{base_url}/api/v1/path").mock(
+        side_effect=[ConnectError, ConnectError, NetworkError, Response(200)],
+    )
+
+    retry_client = BaseClient(base_url, "token", retries=10)
+    retry_client.request("get", "/path", handler=ok_handler)
+
+    assert route.called
+    assert route.call_count == 4
+
+
 def test_patch(respx_mock):
-    route = respx_mock.patch("http://baseurl/api/v1/path").mock(
+    route = respx_mock.patch(f"{base_url}/api/v1/path").mock(
         return_value=Response(200, text="success")
     )
     resp = client.request("patch", "/path", json={"a": "b"}, handler=ok_handler)
@@ -137,12 +163,12 @@ def test_patch(respx_mock):
 
 def test_patch_exception_authorization(respx_mock):
     with pytest.raises(UnauthorizedException):
-        respx_mock.patch("http://baseurl/api/v1/path").mock(return_value=Response(401))
+        respx_mock.patch(f"{base_url}/api/v1/path").mock(return_value=Response(401))
         client.request("patch", "/path", json={"a": "b"}, handler=ok_handler)
 
 
 def test_post(respx_mock):
-    route = respx_mock.post("http://baseurl/api/v1/path").mock(
+    route = respx_mock.post(f"{base_url}/api/v1/path").mock(
         return_value=Response(200, text="success")
     )
     resp = client.request("post", "/path", json={"a": "b"}, handler=ok_handler)
@@ -155,5 +181,5 @@ def test_post(respx_mock):
 
 def test_post_exception_authorization(respx_mock):
     with pytest.raises(UnauthorizedException):
-        respx_mock.post("http://baseurl/api/v1/path").mock(return_value=Response(401))
+        respx_mock.post(f"{base_url}/api/v1/path").mock(return_value=Response(401))
         client.request("post", "/path", handler=ok_handler)
diff --git a/tests/test_dogma.py b/tests/test_dogma.py
index a37e309..e566dce 100644
--- a/tests/test_dogma.py
+++ b/tests/test_dogma.py
@@ -37,7 +37,8 @@
     RepositoryExistsException,
 )
 
-client = Dogma("http://baseurl", "token")
+base_url = "http://baseurl"
+client = Dogma(base_url, "token", retries=0)
 
 mock_project = {
     "name": "project1",
@@ -79,7 +80,7 @@
 
 
 def test_list_projects(respx_mock):
-    url = "http://baseurl/api/v1/projects"
+    url = f"{base_url}/api/v1/projects"
     route = respx_mock.get(url).mock(
         return_value=Response(200, json=[mock_project, mock_project])
     )
@@ -97,7 +98,7 @@ def test_list_projects(respx_mock):
 
 
 def test_list_projects_removed(respx_mock):
-    url = "http://baseurl/api/v1/projects"
+    url = f"{base_url}/api/v1/projects"
     route = respx_mock.get(url).mock(return_value=Response(HTTPStatus.NO_CONTENT))
     projects = client.list_projects(removed=True)
 
@@ -107,7 +108,7 @@ def test_list_projects_removed(respx_mock):
 
 
 def test_create_project(respx_mock):
-    url = "http://baseurl/api/v1/projects"
+    url = f"{base_url}/api/v1/projects"
     route = respx_mock.post(url).mock(
         return_value=Response(HTTPStatus.CREATED, json=mock_project)
     )
@@ -121,7 +122,7 @@ def test_create_project(respx_mock):
 
 
 def test_create_project_failed(respx_mock):
-    url = "http://baseurl/api/v1/projects"
+    url = f"{base_url}/api/v1/projects"
     response_body = {
         "exception": "com.linecorp.centraldogma.common.ProjectExistsException",
         "message": "Project 'newProject' exists already.",
@@ -140,7 +141,7 @@ def test_create_project_failed(respx_mock):
 
 
 def test_remove_project(respx_mock):
-    url = "http://baseurl/api/v1/projects/project1"
+    url = f"{base_url}/api/v1/projects/project1"
     route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.NO_CONTENT))
     client.remove_project("project1")
 
@@ -149,7 +150,7 @@ def test_remove_project(respx_mock):
 
 
 def test_remove_project_failed(respx_mock):
-    url = "http://baseurl/api/v1/projects/project1"
+    url = f"{base_url}/api/v1/projects/project1"
     route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.BAD_REQUEST))
     with pytest.raises(BadRequestException):
         client.remove_project("project1")
@@ -159,7 +160,7 @@ def test_remove_project_failed(respx_mock):
 
 
 def test_unremove_project(respx_mock):
-    url = "http://baseurl/api/v1/projects/project1"
+    url = f"{base_url}/api/v1/projects/project1"
     route = respx_mock.patch(url).mock(
         return_value=Response(HTTPStatus.OK, json=mock_project)
     )
@@ -175,7 +176,7 @@ def test_unremove_project(respx_mock):
 
 
 def test_unremove_project_failed(respx_mock):
-    url = "http://baseurl/api/v1/projects/project1"
+    url = f"{base_url}/api/v1/projects/project1"
     route = respx_mock.patch(url).mock(
         return_value=Response(HTTPStatus.SERVICE_UNAVAILABLE)
     )
@@ -191,7 +192,7 @@ def test_unremove_project_failed(respx_mock):
 
 
 def test_purge_project(respx_mock):
-    url = "http://baseurl/api/v1/projects/project1/removed"
+    url = f"{base_url}/api/v1/projects/project1/removed"
     route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.NO_CONTENT))
     client.purge_project("project1")
 
@@ -200,7 +201,7 @@ def test_purge_project(respx_mock):
 
 
 def test_purge_project_failed(respx_mock):
-    url = "http://baseurl/api/v1/projects/project1/removed"
+    url = f"{base_url}/api/v1/projects/project1/removed"
     route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.FORBIDDEN))
     with pytest.raises(UnknownException):
         client.purge_project("project1")
@@ -210,7 +211,7 @@ def test_purge_project_failed(respx_mock):
 
 
 def test_list_repositories(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos"
+    url = f"{base_url}/api/v1/projects/myproject/repos"
     route = respx_mock.get(url).mock(
         return_value=Response(HTTPStatus.OK, json=[mock_repository, mock_repository])
     )
@@ -230,7 +231,7 @@ def test_list_repositories(respx_mock):
 
 
 def test_list_repositories_removed(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos"
+    url = f"{base_url}/api/v1/projects/myproject/repos"
     route = respx_mock.get(url).mock(return_value=Response(HTTPStatus.NO_CONTENT))
     repos = client.list_repositories("myproject", removed=True)
 
@@ -240,7 +241,7 @@ def test_list_repositories_removed(respx_mock):
 
 
 def test_create_repository(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos"
+    url = f"{base_url}/api/v1/projects/myproject/repos"
     route = respx_mock.post(url).mock(
         return_value=Response(HTTPStatus.CREATED, json=mock_repository)
     )
@@ -254,7 +255,7 @@ def test_create_repository(respx_mock):
 
 
 def test_create_repository_failed(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos"
+    url = f"{base_url}/api/v1/projects/myproject/repos"
     response_body = {
         "exception": "com.linecorp.centraldogma.common.RepositoryExistsException",
         "message": "Respository 'myproject/newRepo' exists already.",
@@ -272,7 +273,7 @@ def test_create_repository_failed(respx_mock):
 
 
 def test_remove_repository(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo"
     route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.NO_CONTENT))
     client.remove_repository("myproject", "myrepo")
 
@@ -281,7 +282,7 @@ def test_remove_repository(respx_mock):
 
 
 def test_remove_repository_failed(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo"
     route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.FORBIDDEN))
     with pytest.raises(UnknownException):
         client.remove_repository("myproject", "myrepo")
@@ -291,7 +292,7 @@ def test_remove_repository_failed(respx_mock):
 
 
 def test_unremove_repository(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo"
     route = respx_mock.patch(url).mock(
         return_value=Response(HTTPStatus.OK, json=mock_repository)
     )
@@ -307,7 +308,7 @@ def test_unremove_repository(respx_mock):
 
 
 def test_unremove_repository_failed(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo"
     route = respx_mock.patch(url).mock(return_value=Response(HTTPStatus.BAD_REQUEST))
     with pytest.raises(BadRequestException):
         client.unremove_repository("myproject", "myrepo")
@@ -321,7 +322,7 @@ def test_unremove_repository_failed(respx_mock):
 
 
 def test_purge_repository(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo/removed"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/removed"
     route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.NO_CONTENT))
     client.purge_repository("myproject", "myrepo")
 
@@ -330,7 +331,7 @@ def test_purge_repository(respx_mock):
 
 
 def test_purge_repository_failed(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo/removed"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/removed"
     route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.FORBIDDEN))
     with pytest.raises(UnknownException):
         client.purge_repository("myproject", "myrepo")
@@ -340,7 +341,7 @@ def test_purge_repository_failed(respx_mock):
 
 
 def test_normalize_repository_revision(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo/revision/3"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/revision/3"
     route = respx_mock.get(url).mock(
         return_value=Response(HTTPStatus.OK, json={"revision": 3})
     )
@@ -352,7 +353,7 @@ def test_normalize_repository_revision(respx_mock):
 
 
 def test_normalize_repository_revision_failed(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo/revision/3"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/revision/3"
     route = respx_mock.get(url).mock(return_value=Response(HTTPStatus.BAD_REQUEST))
     with pytest.raises(BadRequestException):
         client.normalize_repository_revision("myproject", "myrepo", 3)
@@ -362,7 +363,7 @@ def test_normalize_repository_revision_failed(respx_mock):
 
 
 def test_list_files(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo/list"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/list"
     route = respx_mock.get(url).mock(
         return_value=Response(
             HTTPStatus.OK, json=[mock_content_text, mock_content_text]
@@ -380,7 +381,7 @@ def test_list_files(respx_mock):
 
 
 def test_list_files_pattern(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo/list/foo/*.json"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/list/foo/*.json"
     route = respx_mock.get(url).mock(return_value=Response(HTTPStatus.NO_CONTENT))
     files = client.list_files("myproject", "myrepo", "/foo/*.json")
 
@@ -390,7 +391,7 @@ def test_list_files_pattern(respx_mock):
 
 
 def test_list_files_revision(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo/list/*.json"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/list/*.json"
     route = respx_mock.get(url).mock(return_value=Response(HTTPStatus.NO_CONTENT))
     files = client.list_files("myproject", "myrepo", "*.json", 3)
 
@@ -400,7 +401,7 @@ def test_list_files_revision(respx_mock):
 
 
 def test_get_files(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo/contents"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/contents"
     route = respx_mock.get(url).mock(
         return_value=Response(
             HTTPStatus.OK, json=[mock_content_text, mock_content_text]
@@ -416,7 +417,7 @@ def test_get_files(respx_mock):
 
 
 def test_get_files_pattern(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo/contents/foo/*.json"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/contents/foo/*.json"
     route = respx_mock.get(url).mock(return_value=Response(HTTPStatus.NO_CONTENT))
     files = client.get_files("myproject", "myrepo", "/foo/*.json")
 
@@ -426,7 +427,7 @@ def test_get_files_pattern(respx_mock):
 
 
 def test_get_files_revision(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo/contents/*.json"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/contents/*.json"
     route = respx_mock.get(url).mock(return_value=Response(HTTPStatus.NO_CONTENT))
     files = client.get_files("myproject", "myrepo", "*.json", 3)
 
@@ -436,7 +437,7 @@ def test_get_files_revision(respx_mock):
 
 
 def test_get_file_text(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo/contents/foo.text"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/contents/foo.text"
     route = respx_mock.get(url).mock(
         return_value=Response(HTTPStatus.OK, json=mock_content_text)
     )
@@ -453,7 +454,7 @@ def test_get_file_text(respx_mock):
 
 
 def test_get_file_json(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo/contents/foo.json"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/contents/foo.json"
     route = respx_mock.get(url).mock(
         return_value=Response(HTTPStatus.OK, json=mock_content_json)
     )
@@ -470,7 +471,7 @@ def test_get_file_json(respx_mock):
 
 
 def test_get_file_json_path(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo/contents/foo.json"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/contents/foo.json"
     route = respx_mock.get(url).mock(
         return_value=Response(HTTPStatus.OK, json=mock_content_json)
     )
@@ -487,7 +488,7 @@ def test_get_file_json_path(respx_mock):
 
 
 def test_push(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo/contents"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/contents"
     route = respx_mock.post(url).mock(
         return_value=Response(HTTPStatus.OK, json=mock_push_result)
     )
@@ -509,7 +510,7 @@ def test_push(respx_mock):
 
 
 def test_merge(respx_mock):
-    url = "http://baseurl/api/v1/projects/myproject/repos/myrepo/merge?optional_path=test.json&optional_path=test2.json&path=test3.json"
+    url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/merge?optional_path=test.json&optional_path=test2.json&path=test3.json"
     route = respx_mock.get(url).mock(
         return_value=Response(HTTPStatus.OK, json=mock_merge_result)
     )
diff --git a/tests/test_push_result.py b/tests/test_push_result.py
index c18c99a..820f72f 100644
--- a/tests/test_push_result.py
+++ b/tests/test_push_result.py
@@ -11,7 +11,6 @@
 #  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 #  License for the specific language governing permissions and limitations
 #  under the License.
-
 from dateutil import parser
 
 from centraldogma.data import PushResult
diff --git a/tests/test_repository_watcher.py b/tests/test_repository_watcher.py
index 2d517cb..15b1b54 100644
--- a/tests/test_repository_watcher.py
+++ b/tests/test_repository_watcher.py
@@ -11,7 +11,6 @@
 #  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 #  License for the specific language governing permissions and limitations
 #  under the License.
-
 from centraldogma.data.revision import Revision
 from centraldogma.query import Query
 from centraldogma.repository_watcher import RepositoryWatcher, FileWatcher