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

Issue:
Raise proper exceptions rather then return optional values
I found some APIs whose return type should be `Optional` type. So the return type needs to be wrapped with `Optional`.
https://github.com/line/centraldogma-python/blob/faae4059636e63f5385502fd72b22f3a012d8e1b/centraldogma/repository_service.py#L34-L40
While I tried to use `Optional[Repository]`, I thought that it would be better to raise a specific exception such as `ProjectExistsException` 
rather than `None`. Because the meaning of `None` is pretty implicit.
https://github.com/line/centraldogma/blob/b167d594af5abc06af30d7d6d7d8b68b320861d8/client/java-armeria/src/main/java/com/linecorp/centraldogma/client/armeria/ArmeriaCentralDogma.java#L119-L132
We can see `exception` of error response for decoding to proper exceptions. 
https://github.com/line/centraldogma/blob/b167d594af5abc06af30d7d6d7d8b68b320861d8/client/java-armeria/src/main/java/com/linecorp/centraldogma/client/armeria/ArmeriaCentralDogma.java#L1159-L1163


Discussion / hints:

Fix patch:
diff --git a/centraldogma/base_client.py b/centraldogma/base_client.py
index 00f4b33..fb679ae 100644
--- a/centraldogma/base_client.py
+++ b/centraldogma/base_client.py
@@ -11,16 +11,13 @@
 # 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.exceptions import (
-    BadRequestException,
-    ConflictException,
-    NotFoundException,
-    UnauthorizedException,
-    UnknownException,
-)
-from http import HTTPStatus
+from typing import Dict, Union, Callable, TypeVar, Optional
+
 from httpx import Client, Response
-from typing import Dict, Union
+
+from centraldogma.exceptions import to_exception
+
+T = TypeVar("T")
 
 
 class BaseClient:
@@ -35,17 +32,27 @@ def __init__(self, base_url: str, token: str, http2: bool = True, **configs):
         self.headers = self._get_headers(token)
         self.patch_headers = self._get_patch_headers(token)
 
-    def request(self, method: str, path: str, **kwargs) -> Union[Response]:
+    def request(
+        self,
+        method: str,
+        path: str,
+        handler: Optional[dict[int, Callable[[Response], T]]] = None,
+        **kwargs,
+    ) -> Union[Response, T]:
         kwargs = self._set_request_headers(method, **kwargs)
-        return self._httpx_request(method, path, **kwargs)
+        resp = self.client.request(method, path, **kwargs)
+        if handler:
+            converter = handler.get(resp.status_code)
+            if converter:
+                return converter(resp)
+            else:  # Unexpected response status
+                raise to_exception(resp)
+        return resp
 
     def _set_request_headers(self, method: str, **kwargs) -> Dict:
         kwargs["headers"] = self.patch_headers if method == "patch" else self.headers
         return kwargs
 
-    def _httpx_request(self, method: str, url: str, **kwargs) -> Response:
-        return self._handle_response(self.client.request(method, url, **kwargs))
-
     @staticmethod
     def _get_headers(token: str) -> Dict:
         return {
@@ -59,22 +66,3 @@ def _get_patch_headers(token: str) -> Dict:
             "Authorization": f"bearer {token}",
             "Content-Type": "application/json-patch+json",
         }
-
-    @staticmethod
-    def _handle_response(response: Response):
-        if response.is_error:
-            BaseClient._handle_exception(response)
-        return response
-
-    @staticmethod
-    def _handle_exception(response: Response):
-        if response.status_code == HTTPStatus.UNAUTHORIZED:
-            raise UnauthorizedException(response)
-        elif response.status_code == HTTPStatus.BAD_REQUEST:
-            raise BadRequestException(response)
-        elif response.status_code == HTTPStatus.NOT_FOUND:
-            raise NotFoundException(response)
-        elif response.status_code == HTTPStatus.CONFLICT:
-            raise ConflictException(response)
-        else:
-            raise UnknownException(response)
diff --git a/centraldogma/content_service.py b/centraldogma/content_service.py
index 66d1461..c7f767a 100644
--- a/centraldogma/content_service.py
+++ b/centraldogma/content_service.py
@@ -11,16 +11,17 @@
 # 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.base_client import BaseClient
-from centraldogma.data.change import Change
-from centraldogma.data.commit import Commit
-from centraldogma.data.content import Content
-from centraldogma.data.push_result import PushResult
 from dataclasses import asdict
 from enum import Enum
 from http import HTTPStatus
 from typing import List, Optional
 
+from centraldogma.base_client import BaseClient
+from centraldogma.data import Content
+from centraldogma.data.change import Change
+from centraldogma.data.commit import Commit
+from centraldogma.data.push_result import PushResult
+
 
 class ContentService:
     def __init__(self, client: BaseClient):
@@ -42,10 +43,14 @@ def get_files(
                 path += path_pattern
             else:
                 path += "/" + path_pattern
-        resp = self.client.request("get", path, params=params)
-        if resp.status_code == HTTPStatus.NO_CONTENT:
-            return []
-        return [Content.from_dict(content) for content in resp.json()]
+
+        handler = {
+            HTTPStatus.OK: lambda resp: [
+                Content.from_dict(content) for content in resp.json()
+            ],
+            HTTPStatus.NO_CONTENT: lambda resp: [],
+        }
+        return self.client.request("get", path, params=params, handler=handler)
 
     def get_file(
         self,
@@ -63,8 +68,9 @@ def get_file(
         if not file_path.startswith("/"):
             file_path = "/" + file_path
         path = f"/projects/{project_name}/repos/{repo_name}/contents{file_path}"
-        resp = self.client.request("get", path, params=params)
-        return Content.from_dict(resp.json())
+
+        handler = {HTTPStatus.OK: lambda resp: Content.from_dict(resp.json())}
+        return self.client.request("get", path, params=params, handler=handler)
 
     def push(
         self,
@@ -80,10 +86,11 @@ def push(
             ],
         }
         path = f"/projects/{project_name}/repos/{repo_name}/contents"
-        resp = self.client.request("post", path, json=params)
-        return PushResult.from_dict(resp.json())
+        handler = {HTTPStatus.OK: lambda resp: PushResult.from_dict(resp.json())}
+        return self.client.request("post", path, json=params, handler=handler)
 
-    def _change_dict(self, data):
+    @staticmethod
+    def _change_dict(data):
         return {
             field: value.value if isinstance(value, Enum) else value
             for field, value in data
diff --git a/centraldogma/data/__init__.py b/centraldogma/data/__init__.py
index 9886620..424ac8f 100644
--- a/centraldogma/data/__init__.py
+++ b/centraldogma/data/__init__.py
@@ -11,9 +11,9 @@
 # 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 .constants import DATE_FORMAT_ISO8601, DATE_FORMAT_ISO8601_MS
 from .change import Change, ChangeType
 from .commit import Commit
+from .constants import DATE_FORMAT_ISO8601, DATE_FORMAT_ISO8601_MS
 from .content import Content
 from .creator import Creator
 from .project import Project
diff --git a/centraldogma/data/change.py b/centraldogma/data/change.py
index 9b1bd49..c8a0988 100644
--- a/centraldogma/data/change.py
+++ b/centraldogma/data/change.py
@@ -12,10 +12,11 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 from dataclasses import dataclass
-from dataclasses_json import dataclass_json
 from enum import Enum
 from typing import Optional, Union
 
+from dataclasses_json import dataclass_json
+
 
 class ChangeType(Enum):
     UPSERT_JSON = "UPSERT_JSON"
diff --git a/centraldogma/data/commit.py b/centraldogma/data/commit.py
index 544c870..9e7a566 100644
--- a/centraldogma/data/commit.py
+++ b/centraldogma/data/commit.py
@@ -12,9 +12,10 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 from dataclasses import dataclass
-from dataclasses_json import dataclass_json
 from typing import Optional
 
+from dataclasses_json import dataclass_json
+
 
 @dataclass_json
 @dataclass
diff --git a/centraldogma/data/content.py b/centraldogma/data/content.py
index 89db028..e789760 100644
--- a/centraldogma/data/content.py
+++ b/centraldogma/data/content.py
@@ -12,9 +12,10 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 from dataclasses import dataclass
-from dataclasses_json import dataclass_json
 from typing import Any, Optional
 
+from dataclasses_json import dataclass_json
+
 
 @dataclass_json
 @dataclass
diff --git a/centraldogma/data/creator.py b/centraldogma/data/creator.py
index 1e92362..58734a8 100644
--- a/centraldogma/data/creator.py
+++ b/centraldogma/data/creator.py
@@ -12,6 +12,7 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 from dataclasses import dataclass
+
 from dataclasses_json import dataclass_json
 
 
diff --git a/centraldogma/data/project.py b/centraldogma/data/project.py
index 42b575c..d769a97 100644
--- a/centraldogma/data/project.py
+++ b/centraldogma/data/project.py
@@ -11,14 +11,16 @@
 # 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.constants import DATE_FORMAT_ISO8601
-from centraldogma.data.creator import Creator
 from dataclasses import dataclass, field
-from dataclasses_json import LetterCase, config, dataclass_json
 from datetime import datetime
-from marshmallow import fields
 from typing import Optional
 
+from dataclasses_json import LetterCase, config, dataclass_json
+from marshmallow import fields
+
+from centraldogma.data.constants import DATE_FORMAT_ISO8601
+from centraldogma.data.creator import Creator
+
 
 @dataclass_json(letter_case=LetterCase.CAMEL)
 @dataclass
diff --git a/centraldogma/data/push_result.py b/centraldogma/data/push_result.py
index 9d7b1d8..9271f47 100644
--- a/centraldogma/data/push_result.py
+++ b/centraldogma/data/push_result.py
@@ -11,12 +11,14 @@
 # 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.constants import DATE_FORMAT_ISO8601_MS
 from dataclasses import dataclass, field
-from dataclasses_json import LetterCase, config, dataclass_json
 from datetime import datetime
+
+from dataclasses_json import LetterCase, config, dataclass_json
 from marshmallow import fields
 
+from centraldogma.data.constants import DATE_FORMAT_ISO8601_MS
+
 
 @dataclass_json(letter_case=LetterCase.CAMEL)
 @dataclass
diff --git a/centraldogma/data/repository.py b/centraldogma/data/repository.py
index d6413be..b75ff3a 100644
--- a/centraldogma/data/repository.py
+++ b/centraldogma/data/repository.py
@@ -11,14 +11,16 @@
 # 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.constants import DATE_FORMAT_ISO8601
-from centraldogma.data.creator import Creator
 from dataclasses import dataclass, field
-from dataclasses_json import LetterCase, config, dataclass_json
 from datetime import datetime
-from marshmallow import fields
 from typing import Optional
 
+from dataclasses_json import LetterCase, config, dataclass_json
+from marshmallow import fields
+
+from centraldogma.data.constants import DATE_FORMAT_ISO8601
+from centraldogma.data.creator import Creator
+
 
 @dataclass_json(letter_case=LetterCase.CAMEL)
 @dataclass
diff --git a/centraldogma/dogma.py b/centraldogma/dogma.py
index 81ed0c6..58a3ef0 100644
--- a/centraldogma/dogma.py
+++ b/centraldogma/dogma.py
@@ -11,21 +11,24 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations
 # under the License.
+import os
+from typing import List, Optional
+
 from centraldogma.base_client import BaseClient
 from centraldogma.content_service import ContentService
+
+# noinspection PyUnresolvedReferences
 from centraldogma.data import (
     Change,
-    ChangeType,
     Commit,
     Content,
+    ChangeType,
     Project,
     PushResult,
     Repository,
 )
 from centraldogma.project_service import ProjectService
 from centraldogma.repository_service import RepositoryService
-from typing import List, Optional
-import os
 
 
 class Dogma:
@@ -59,7 +62,7 @@ def create_project(self, name: str) -> Project:
         """Creates a project. The creator of the project will become the owner of the project."""
         return self.project_service.create(name)
 
-    def remove_project(self, name: str) -> bool:
+    def remove_project(self, name: str) -> None:
         """Removes a project. Only the owner and an admin can remove the project."""
         return self.project_service.remove(name)
 
@@ -67,7 +70,7 @@ def unremove_project(self, name: str) -> Project:
         """Unremoves a project which is removed before. Only an admin can unremove the project."""
         return self.project_service.unremove(name)
 
-    def purge_project(self, name: str) -> bool:
+    def purge_project(self, name: str) -> None:
         """Purges a project. Only the owner and an admin can purge the project removed before."""
         return self.project_service.purge(name)
 
@@ -81,7 +84,7 @@ def create_repository(self, project_name: str, name: str) -> Repository:
         """Creates a repository. Only the owner and an admin can create."""
         return self.repository_service.create(project_name, name)
 
-    def remove_repository(self, project_name: str, name: str) -> bool:
+    def remove_repository(self, project_name: str, name: str) -> None:
         """Removes a repository. Only the owner and an admin can remove."""
         return self.repository_service.remove(project_name, name)
 
@@ -89,7 +92,7 @@ def unremove_repository(self, project_name: str, name: str) -> Repository:
         """Unremoves a repository. Only the owner and an admin can unremove."""
         return self.repository_service.unremove(project_name, name)
 
-    def purge_repository(self, project_name: str, name: str) -> bool:
+    def purge_repository(self, project_name: str, name: str) -> None:
         """Purges a repository. Only the owner and an admin can purge a repository removed before."""
         return self.repository_service.purge(project_name, name)
 
diff --git a/centraldogma/exceptions.py b/centraldogma/exceptions.py
index 8445073..786d577 100644
--- a/centraldogma/exceptions.py
+++ b/centraldogma/exceptions.py
@@ -11,37 +11,201 @@
 # 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 http import HTTPStatus
+from json import JSONDecodeError
+from typing import Callable
+
 from httpx import Response
 
 
 class CentralDogmaException(Exception):
-    def __init__(self, response: Response):
-        try:
-            self.response = response.json()
-            self.exception = self.response["exception"]
-            self.message = self.response["message"]
-        except:
-            print("The response format is not expected")
+    """
+    A exception that is raised when failed to access Central Dogma.
+    """
 
-    def __str__(self):
-        return str(self.response)
+    pass
 
 
 class BadRequestException(CentralDogmaException):
-    pass
+    """
+    A exception indicating a 400 Bad Client Request.
+    """
 
-
-class ConflictException(CentralDogmaException):
     pass
 
 
 class NotFoundException(CentralDogmaException):
+    """
+    A exception indicating a 404 Not Found.
+    """
+
     pass
 
 
 class UnauthorizedException(CentralDogmaException):
+    """
+    A exception indicating a 401 Unauthorized.
+    """
+
+    pass
+
+
+class ForbiddenException(CentralDogmaException):
+    """
+    A exception indicating that an access to a resource requested by a client has been forbidden by the Central Dogma.
+    """
+
     pass
 
 
 class UnknownException(CentralDogmaException):
+    """
+    A exception used for reporting unknown exceptions
+    """
+
+    pass
+
+
+class ProjectExistsException(CentralDogmaException):
+    """
+    A ``CentralDogmaException`` that is raised when attempted to create a project with an existing project name.
+    """
+
+    pass
+
+
+class ProjectNotFoundException(CentralDogmaException):
+    """
+    A ``CentralDogmaException`` that is raised when attempted to access a non-existent project.
+    """
+
+    pass
+
+
+class QueryExecutionException(CentralDogmaException):
+    """
+    A ``CentralDogmaException`` that is raised when the evaluation of a `Query` has failed.
+    """
+
+    pass
+
+
+class RedundantChangeException(CentralDogmaException):
+    """
+    A ``CentralDogmaException`` that is raised when attempted to push a commit without effective changes.
+    """
+
+    pass
+
+
+class RevisionNotFoundException(CentralDogmaException):
+    """
+    A ``CentralDogmaException`` that is raised when attempted to access a non-existent revision.
+    """
+
+    pass
+
+
+class EntryNotFoundException(CentralDogmaException):
+    """
+    A ``CentralDogmaException`` that is raised when attempted to access a non-existent entry in a repository.
+    """
+
+    pass
+
+
+class ChangeConflictException(CentralDogmaException):
+    """
+    A ``CentralDogmaException`` that is raised when attempted to push a commit which cannot be applied without a conflict.
+    """
+
+    pass
+
+
+class RepositoryNotFoundException(CentralDogmaException):
+    """
+    A ``CentralDogmaException`` that is raised when attempted to access a non-existent repository.
+    """
+
+    pass
+
+
+class AuthorizationException(CentralDogmaException):
+    """
+    A ``CentralDogmaException`` that is raised when a client failed to authenticate or attempted to
+    perform an unauthorized operation.
+    """
+
+    pass
+
+
+class ShuttingDownException(CentralDogmaException):
+    """
+    A ``CentralDogmaException`` that is raised when Central Dogma cannot handle a request because it's shutting down.
+    """
+
     pass
+
+
+class RepositoryExistsException(CentralDogmaException):
+    """
+    A ``CentralDogmaException`` that is raised when attempted to create a repository with an existing repository name.
+    """
+
+    pass
+
+
+class InvalidResponseException(CentralDogmaException):
+    """
+    A ``CentralDogmaException`` that is raised when a client received an invalid response.
+    """
+
+    pass
+
+
+_EXCEPTION_FACTORIES: dict[str, Callable[[str], CentralDogmaException]] = {
+    "com.linecorp.centraldogma.common." + exception.__name__: exception
+    for exception in [
+        ProjectExistsException,
+        ProjectNotFoundException,
+        QueryExecutionException,
+        RedundantChangeException,
+        RevisionNotFoundException,
+        EntryNotFoundException,
+        ChangeConflictException,
+        RepositoryNotFoundException,
+        AuthorizationException,
+        ShuttingDownException,
+        RepositoryExistsException,
+    ]
+}
+
+
+def to_exception(response: Response) -> CentralDogmaException:
+    if not response.text:
+        return _to_status_exception(response.status_code, "")
+
+    try:
+        body = response.json()
+    except JSONDecodeError:
+        return InvalidResponseException(response.text)
+
+    exception = body.get("exception")
+    message = body.get("message") or response.text
+    if exception:
+        exception_type = _EXCEPTION_FACTORIES.get(exception)
+        if exception_type:
+            return exception_type(message)
+
+    return _to_status_exception(response.status_code, message)
+
+
+def _to_status_exception(status: int, message: str) -> CentralDogmaException:
+    if status == HTTPStatus.UNAUTHORIZED:
+        return UnauthorizedException(message)
+    elif status == HTTPStatus.BAD_REQUEST:
+        return BadRequestException(message)
+    elif status == HTTPStatus.NOT_FOUND:
+        return NotFoundException(message)
+    else:
+        return UnknownException(message)
diff --git a/centraldogma/project_service.py b/centraldogma/project_service.py
index 6b97b7e..d9cd4dd 100644
--- a/centraldogma/project_service.py
+++ b/centraldogma/project_service.py
@@ -11,11 +11,12 @@
 # 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.base_client import BaseClient
-from centraldogma.data import Project
 from http import HTTPStatus
 from typing import List
 
+from centraldogma.base_client import BaseClient
+from centraldogma.data import Project
+
 
 class ProjectService:
     def __init__(self, client: BaseClient):
@@ -23,24 +24,33 @@ def __init__(self, client: BaseClient):
 
     def list(self, removed: bool) -> List[Project]:
         params = {"status": "removed"} if removed else None
-        resp = self.client.request("get", "/projects", params=params)
-        if resp.status_code == HTTPStatus.NO_CONTENT:
-            return []
-        return [Project.from_dict(project) for project in resp.json()]
+        handler = {
+            HTTPStatus.OK: lambda resp: [
+                Project.from_dict(project) for project in resp.json()
+            ],
+            HTTPStatus.NO_CONTENT: lambda resp: [],
+        }
+        return self.client.request("get", "/projects", params=params, handler=handler)
 
     def create(self, name: str) -> Project:
-        resp = self.client.request("post", "/projects", json={"name": name})
-        return Project.from_dict(resp.json())
+        handler = {HTTPStatus.CREATED: lambda resp: Project.from_dict(resp.json())}
+        return self.client.request(
+            "post", "/projects", json={"name": name}, handler=handler
+        )
 
-    def remove(self, name: str) -> bool:
-        resp = self.client.request("delete", f"/projects/{name}")
-        return True if resp.status_code == HTTPStatus.NO_CONTENT else False
+    def remove(self, name: str) -> None:
+        handler = {HTTPStatus.NO_CONTENT: lambda resp: None}
+        return self.client.request("delete", f"/projects/{name}", handler=handler)
 
     def unremove(self, name: str) -> Project:
         body = [{"op": "replace", "path": "/status", "value": "active"}]
-        resp = self.client.request("patch", f"/projects/{name}", json=body)
-        return Project.from_dict(resp.json())
+        handler = {HTTPStatus.OK: lambda resp: Project.from_dict(resp.json())}
+        return self.client.request(
+            "patch", f"/projects/{name}", json=body, handler=handler
+        )
 
-    def purge(self, name: str) -> bool:
-        resp = self.client.request("delete", f"/projects/{name}/removed")
-        return True if resp.status_code == HTTPStatus.NO_CONTENT else False
+    def purge(self, name: str) -> None:
+        handler = {HTTPStatus.NO_CONTENT: lambda resp: None}
+        return self.client.request(
+            "delete", f"/projects/{name}/removed", handler=handler
+        )
diff --git a/centraldogma/repository_service.py b/centraldogma/repository_service.py
index 101eed3..688ce8d 100644
--- a/centraldogma/repository_service.py
+++ b/centraldogma/repository_service.py
@@ -11,11 +11,12 @@
 # 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.base_client import BaseClient
-from centraldogma.data import Repository
 from http import HTTPStatus
 from typing import List
 
+from centraldogma.base_client import BaseClient
+from centraldogma.data import Repository
+
 
 class RepositoryService:
     def __init__(self, client: BaseClient):
@@ -23,38 +24,51 @@ def __init__(self, client: BaseClient):
 
     def list(self, project_name: str, removed: bool) -> List[Repository]:
         params = {"status": "removed"} if removed else None
-        resp = self.client.request(
-            "get", f"/projects/{project_name}/repos", params=params
+        handler = {
+            HTTPStatus.OK: lambda resp: [
+                Repository.from_dict(repo) for repo in resp.json()
+            ],
+            HTTPStatus.NO_CONTENT: lambda resp: [],
+        }
+        return self.client.request(
+            "get", f"/projects/{project_name}/repos", params=params, handler=handler
         )
-        if resp.status_code == HTTPStatus.NO_CONTENT:
-            return []
-        return [Repository.from_dict(repo) for repo in resp.json()]
 
     def create(self, project_name: str, name: str) -> Repository:
-        resp = self.client.request(
-            "post", f"/projects/{project_name}/repos", json={"name": name}
+        handler = {HTTPStatus.CREATED: lambda resp: Repository.from_dict(resp.json())}
+        return self.client.request(
+            "post",
+            f"/projects/{project_name}/repos",
+            json={"name": name},
+            handler=handler,
         )
-        return Repository.from_dict(resp.json())
 
-    def remove(self, project_name: str, name: str) -> bool:
-        resp = self.client.request("delete", f"/projects/{project_name}/repos/{name}")
-        return True if resp.status_code == HTTPStatus.NO_CONTENT else False
+    def remove(self, project_name: str, name: str) -> None:
+        handler = {HTTPStatus.NO_CONTENT: lambda resp: None}
+        return self.client.request(
+            "delete", f"/projects/{project_name}/repos/{name}", handler=handler
+        )
 
     def unremove(self, project_name: str, name: str) -> Repository:
         body = [{"op": "replace", "path": "/status", "value": "active"}]
-        resp = self.client.request(
-            "patch", f"/projects/{project_name}/repos/{name}", json=body
+        handler = {HTTPStatus.OK: lambda resp: Repository.from_dict(resp.json())}
+        return self.client.request(
+            "patch",
+            f"/projects/{project_name}/repos/{name}",
+            json=body,
+            handler=handler,
         )
-        return Repository.from_dict(resp.json())
 
-    def purge(self, project_name: str, name: str) -> bool:
-        resp = self.client.request(
-            "delete", f"/projects/{project_name}/repos/{name}/removed"
+    def purge(self, project_name: str, name: str) -> None:
+        handler = {HTTPStatus.NO_CONTENT: lambda resp: None}
+        return self.client.request(
+            "delete", f"/projects/{project_name}/repos/{name}/removed", handler=handler
         )
-        return True if resp.status_code == HTTPStatus.NO_CONTENT else False
 
     def normalize_revision(self, project_name: str, name: str, revision: int) -> int:
-        resp = self.client.request(
-            "get", f"/projects/{project_name}/repos/{name}/revision/{revision}"
+        handler = {HTTPStatus.OK: lambda resp: resp.json()["revision"]}
+        return self.client.request(
+            "get",
+            f"/projects/{project_name}/repos/{name}/revision/{revision}",
+            handler=handler,
         )
-        return resp.json()["revision"] if resp.status_code == HTTPStatus.OK else None


Test patch:
diff --git a/tests/integration/test_content_service.py b/tests/integration/test_content_service.py
index 116194a..47c9482 100644
--- a/tests/integration/test_content_service.py
+++ b/tests/integration/test_content_service.py
@@ -11,11 +11,17 @@
 # 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.dogma import Change, ChangeType, Commit, Dogma
-from centraldogma.exceptions import BadRequestException, ConflictException
-import pytest
 import os
 
+import pytest
+
+from centraldogma.dogma import Change, ChangeType, Commit, Dogma
+from centraldogma.exceptions import (
+    BadRequestException,
+    RedundantChangeException,
+    ChangeConflictException,
+)
+
 dogma = Dogma()
 project_name = "TestProject"
 repo_name = "TestRepository"
@@ -49,7 +55,7 @@ def test_content(run_around_test):
     ret = dogma.push(project_name, repo_name, commit, [upsert_json])
     assert ret.revision == 2
 
-    with pytest.raises(ConflictException):
+    with pytest.raises(RedundantChangeException):
         dogma.push(project_name, repo_name, commit, [upsert_json])
 
     commit = Commit("Upsert test.txt")
@@ -65,9 +71,10 @@ def test_content(run_around_test):
 
     commit = Commit("Rename the json")
     rename_json = Change("/test2.json", ChangeType.RENAME, "/test3.json")
-    with pytest.raises(ConflictException):
+    with pytest.raises(ChangeConflictException):
         dogma.push(project_name, repo_name, commit, [rename_json])
     rename_json = Change("/test.json", ChangeType.RENAME, "")
+
     with pytest.raises(BadRequestException):
         dogma.push(project_name, repo_name, commit, [rename_json])
     rename_json = Change("/test.json", ChangeType.RENAME, "/test2.json")
@@ -81,13 +88,13 @@ def test_content(run_around_test):
 
     commit = Commit("Remove the json")
     remove_json = Change("/test.json", ChangeType.REMOVE)
-    with pytest.raises(ConflictException):
+    with pytest.raises(ChangeConflictException):
         dogma.push(project_name, repo_name, commit, [remove_json])
     remove_json = Change("/test2.json", ChangeType.REMOVE)
     ret = dogma.push(project_name, repo_name, commit, [remove_json])
     assert ret.revision == 6
 
-    with pytest.raises(ConflictException):
+    with pytest.raises(ChangeConflictException):
         dogma.push(project_name, repo_name, commit, [remove_json])
 
     files = dogma.list_files(project_name, repo_name)
diff --git a/tests/integration/test_project_service.py b/tests/integration/test_project_service.py
index a0cf566..f7c7136 100644
--- a/tests/integration/test_project_service.py
+++ b/tests/integration/test_project_service.py
@@ -11,21 +11,41 @@
 # 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.dogma import Dogma
-from centraldogma.exceptions import BadRequestException, NotFoundException
-import pytest
 import os
 
+import pytest
+
+from centraldogma.dogma import Dogma
+from centraldogma.exceptions import (
+    BadRequestException,
+    ProjectNotFoundException,
+)
+
 dogma = Dogma()
 project_name = "TestProject"
 
 
+@pytest.fixture(scope="module")
+def run_around_test():
+    projects = dogma.list_projects()
+    removed_projects = dogma.list_projects(removed=True)
+
+    yield
+
+    for project in dogma.list_projects():
+        if project not in projects:
+            dogma.remove_project(project.name)
+    for removed in dogma.list_projects(removed=True):
+        if removed not in removed_projects:
+            dogma.purge_project(removed.name)
+
+
 @pytest.mark.skipif(
     os.getenv("INTEGRATION_TEST", "false").lower() != "true",
     reason="Integration tests are disabled. Use `INTEGRATION_TEST=true pytest` to enable them.",
 )
 @pytest.mark.integration
-def test_project():
+def test_project(run_around_test):
     with pytest.raises(BadRequestException):
         dogma.create_project("Test project")
 
@@ -36,14 +56,13 @@ def test_project():
     assert new_project.name == project_name
     validate_len(len_project + 1, len_removed_project)
 
-    with pytest.raises(NotFoundException):
+    with pytest.raises(ProjectNotFoundException):
         dogma.remove_project("Non-existent")
 
-    removed = dogma.remove_project(project_name)
-    assert removed == True
+    dogma.remove_project(project_name)
     validate_len(len_project, len_removed_project + 1)
 
-    with pytest.raises(NotFoundException):
+    with pytest.raises(ProjectNotFoundException):
         dogma.unremove_project("Non-existent")
 
     unremoved = dogma.unremove_project(project_name)
@@ -51,8 +70,7 @@ def test_project():
     validate_len(len_project + 1, len_removed_project)
 
     dogma.remove_project(project_name)
-    purged = dogma.purge_project(project_name)
-    assert purged == True
+    dogma.purge_project(project_name)
     validate_len(len_project, len_removed_project)
 
 
diff --git a/tests/integration/test_repository_service.py b/tests/integration/test_repository_service.py
index 8417933..3f32fc9 100644
--- a/tests/integration/test_repository_service.py
+++ b/tests/integration/test_repository_service.py
@@ -12,7 +12,11 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 from centraldogma.dogma import Dogma
-from centraldogma.exceptions import BadRequestException, NotFoundException
+from centraldogma.exceptions import (
+    BadRequestException,
+    NotFoundException,
+    RepositoryNotFoundException,
+)
 import pytest
 import os
 
@@ -23,10 +27,18 @@
 
 @pytest.fixture(scope="module")
 def run_around_test():
+    projects = dogma.list_projects()
+    removed_projects = dogma.list_projects(removed=True)
     dogma.create_project(project_name)
+
     yield
-    dogma.remove_project(project_name)
-    dogma.purge_project(project_name)
+
+    for project in dogma.list_projects():
+        if project not in projects:
+            dogma.remove_project(project.name)
+    for removed in dogma.list_projects(removed=True):
+        if removed not in removed_projects:
+            dogma.purge_project(removed.name)
 
 
 @pytest.mark.skipif(
@@ -40,30 +52,23 @@ def test_repository(run_around_test):
 
     len_repo = len(dogma.list_repositories(project_name))
     len_removed_repo = len(dogma.list_repositories(project_name, removed=True))
-
     repo = dogma.create_repository(project_name, repo_name)
     assert repo.name == repo_name
     validate_len(len_repo + 1, len_removed_repo)
 
-    with pytest.raises(NotFoundException):
+    with pytest.raises(RepositoryNotFoundException):
         dogma.remove_repository(project_name, "Non-existent")
 
-    removed = dogma.remove_repository(project_name, repo_name)
-    assert removed == True
+    dogma.remove_repository(project_name, repo_name)
     validate_len(len_repo, len_removed_repo + 1)
 
-    with pytest.raises(NotFoundException):
+    with pytest.raises(RepositoryNotFoundException):
         dogma.unremove_repository(project_name, "Non-existent")
 
     unremoved = dogma.unremove_repository(project_name, repo_name)
     assert unremoved.name == repo_name
     validate_len(len_repo + 1, len_removed_repo)
 
-    dogma.remove_repository(project_name, repo_name)
-    purged = dogma.purge_repository(project_name, repo_name)
-    assert purged == True
-    validate_len(len_repo, len_removed_repo)
-
 
 def validate_len(expected_len, expected_removed_len):
     repos = dogma.list_repositories(project_name)
diff --git a/tests/test_base_client.py b/tests/test_base_client.py
index 880d23b..9cc8792 100644
--- a/tests/test_base_client.py
+++ b/tests/test_base_client.py
@@ -11,12 +11,13 @@
 # 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 http import HTTPStatus
+
 from centraldogma.exceptions import UnauthorizedException, NotFoundException
 from centraldogma.base_client import BaseClient
 from httpx import Limits, Response
 import pytest
 
-
 client = BaseClient("http://baseurl", "token")
 
 configs = {
@@ -36,6 +37,8 @@
 }
 client_with_configs = BaseClient("http://baseurl", "token", **configs)
 
+ok_handler = {HTTPStatus.OK: lambda resp: resp}
+
 
 def test_set_request_headers():
     for method in ["get", "post", "delete", "patch"]:
@@ -91,14 +94,14 @@ 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))
-        client.request("delete", "/path")
+        client.request("delete", "/path", handler=ok_handler)
 
 
 def test_get(respx_mock):
     route = respx_mock.get("http://baseurl/api/v1/path").mock(
         return_value=Response(200, text="success")
     )
-    resp = client.request("get", "/path", params={"a": "b"})
+    resp = client.request("get", "/path", params={"a": "b"}, handler=ok_handler)
 
     assert route.called
     assert resp.request.headers["Authorization"] == "bearer token"
@@ -109,20 +112,20 @@ 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))
-        client.request("get", "/path")
+        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))
-        client.request("get", "/path")
+        client.request("get", "/path", handler=ok_handler)
 
 
 def test_patch(respx_mock):
     route = respx_mock.patch("http://baseurl/api/v1/path").mock(
         return_value=Response(200, text="success")
     )
-    resp = client.request("patch", "/path", json={"a": "b"})
+    resp = client.request("patch", "/path", json={"a": "b"}, handler=ok_handler)
 
     assert route.called
     assert resp.request.headers["Authorization"] == "bearer token"
@@ -133,14 +136,14 @@ 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))
-        client.request("patch", "/path", json={"a": "b"})
+        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(
         return_value=Response(200, text="success")
     )
-    resp = client.request("post", "/path", json={"a": "b"})
+    resp = client.request("post", "/path", json={"a": "b"}, handler=ok_handler)
 
     assert route.called
     assert resp.request.headers["Authorization"] == "bearer token"
@@ -151,4 +154,4 @@ 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))
-        client.request("post", "/path")
+        client.request("post", "/path", handler=ok_handler)
diff --git a/tests/test_dogma.py b/tests/test_dogma.py
index 2e6aa48..051c877 100644
--- a/tests/test_dogma.py
+++ b/tests/test_dogma.py
@@ -23,13 +23,17 @@
     Repository,
 )
 from centraldogma.dogma import Dogma
-from centraldogma.exceptions import BadRequestException, UnknownException
+from centraldogma.exceptions import (
+    BadRequestException,
+    UnknownException,
+    ProjectExistsException,
+    RepositoryExistsException,
+)
 from datetime import datetime
 from http import HTTPStatus
 from httpx import Response
 import pytest
 
-
 client = Dogma("http://baseurl", "token")
 
 mock_project = {
@@ -109,9 +113,16 @@ def test_create_project(respx_mock):
 
 def test_create_project_failed(respx_mock):
     url = "http://baseurl/api/v1/projects"
-    route = respx_mock.post(url).mock(return_value=Response(HTTPStatus.BAD_REQUEST))
-    with pytest.raises(BadRequestException):
+    response_body = {
+        "exception": "com.linecorp.centraldogma.common.ProjectExistsException",
+        "message": "Project 'newProject' exists already.",
+    }
+    route = respx_mock.post(url).mock(
+        return_value=Response(HTTPStatus.CONFLICT, json=response_body)
+    )
+    with pytest.raises(ProjectExistsException) as cause:
         client.create_project("newProject")
+    assert response_body["message"] == str(cause.value)
 
     assert route.called
     request = respx_mock.calls.last.request
@@ -122,11 +133,10 @@ def test_create_project_failed(respx_mock):
 def test_remove_project(respx_mock):
     url = "http://baseurl/api/v1/projects/project1"
     route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.NO_CONTENT))
-    removed = client.remove_project("project1")
+    client.remove_project("project1")
 
     assert route.called
     assert respx_mock.calls.last.request.url == url
-    assert removed == True
 
 
 def test_remove_project_failed(respx_mock):
@@ -174,11 +184,10 @@ def test_unremove_project_failed(respx_mock):
 def test_purge_project(respx_mock):
     url = "http://baseurl/api/v1/projects/project1/removed"
     route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.NO_CONTENT))
-    purged = client.purge_project("project1")
+    client.purge_project("project1")
 
     assert route.called
     assert respx_mock.calls.last.request.url == url
-    assert purged == True
 
 
 def test_purge_project_failed(respx_mock):
@@ -237,8 +246,14 @@ def test_create_repository(respx_mock):
 
 def test_create_repository_failed(respx_mock):
     url = "http://baseurl/api/v1/projects/myproject/repos"
-    route = respx_mock.post(url).mock(return_value=Response(HTTPStatus.BAD_REQUEST))
-    with pytest.raises(BadRequestException):
+    response_body = {
+        "exception": "com.linecorp.centraldogma.common.RepositoryExistsException",
+        "message": "Respository 'myproject/newRepo' exists already.",
+    }
+    route = respx_mock.post(url).mock(
+        return_value=Response(HTTPStatus.CONFLICT, json=response_body)
+    )
+    with pytest.raises(RepositoryExistsException):
         client.create_repository("myproject", "newRepo")
 
     assert route.called
@@ -250,11 +265,10 @@ def test_create_repository_failed(respx_mock):
 def test_remove_repository(respx_mock):
     url = "http://baseurl/api/v1/projects/myproject/repos/myrepo"
     route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.NO_CONTENT))
-    removed = client.remove_repository("myproject", "myrepo")
+    client.remove_repository("myproject", "myrepo")
 
     assert route.called
     assert respx_mock.calls.last.request.url == url
-    assert removed == True
 
 
 def test_remove_repository_failed(respx_mock):
@@ -300,11 +314,10 @@ def test_unremove_repository_failed(respx_mock):
 def test_purge_repository(respx_mock):
     url = "http://baseurl/api/v1/projects/myproject/repos/myrepo/removed"
     route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.NO_CONTENT))
-    perged = client.purge_repository("myproject", "myrepo")
+    client.purge_repository("myproject", "myrepo")
 
     assert route.called
     assert respx_mock.calls.last.request.url == url
-    assert perged == True
 
 
 def test_purge_repository_failed(respx_mock):
@@ -467,7 +480,7 @@ def test_get_file_json_path(respx_mock):
 def test_push(respx_mock):
     url = "http://baseurl/api/v1/projects/myproject/repos/myrepo/contents"
     route = respx_mock.post(url).mock(
-        return_value=Response(HTTPStatus.CREATED, json=mock_push_result)
+        return_value=Response(HTTPStatus.OK, json=mock_push_result)
     )
     commit = Commit("Upsert test.json")
     upsert_json = Change("/test.json", ChangeType.UPSERT_JSON, {"foo": "bar"})
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
new file mode 100644
index 0000000..55e75e1
--- /dev/null
+++ b/tests/test_exceptions.py
@@ -0,0 +1,112 @@
+#  Copyright 2021 LINE Corporation
+#
+#  LINE Corporation licenses this file to you 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:
+#
+#    https://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.
+import json
+from http import HTTPStatus
+from typing import Any
+
+import pytest
+
+from centraldogma.exceptions import (
+    to_exception,
+    ProjectExistsException,
+    InvalidResponseException,
+    ProjectNotFoundException,
+    QueryExecutionException,
+    RedundantChangeException,
+    RevisionNotFoundException,
+    EntryNotFoundException,
+    ChangeConflictException,
+    RepositoryNotFoundException,
+    AuthorizationException,
+    ShuttingDownException,
+    RepositoryExistsException,
+    BadRequestException,
+    UnauthorizedException,
+    NotFoundException,
+    UnknownException,
+)
+
+
+@pytest.mark.parametrize(
+    "exception,expected_type",
+    [
+        ("ProjectExistsException", ProjectExistsException),
+        ("ProjectNotFoundException", ProjectNotFoundException),
+        ("QueryExecutionException", QueryExecutionException),
+        ("RedundantChangeException", RedundantChangeException),
+        ("RevisionNotFoundException", RevisionNotFoundException),
+        ("EntryNotFoundException", EntryNotFoundException),
+        ("ChangeConflictException", ChangeConflictException),
+        ("RepositoryNotFoundException", RepositoryNotFoundException),
+        ("AuthorizationException", AuthorizationException),
+        ("ShuttingDownException", ShuttingDownException),
+        ("RepositoryExistsException", RepositoryExistsException),
+    ],
+)
+def test_repository_exists_exception(exception, expected_type):
+    class MockResponse:
+        def json(self) -> Any:
+            return {
+                "exception": f"com.linecorp.centraldogma.common.{exception}",
+                "message": "foobar",
+            }
+
+        def text(self) -> str:
+            return json.dumps(self.json())
+
+    # noinspection PyTypeChecker
+    exception = to_exception(MockResponse())
+    assert isinstance(exception, expected_type)
+    assert str(exception) == "foobar"
+
+
+@pytest.mark.parametrize(
+    "status,expected_type",
+    [
+        (HTTPStatus.UNAUTHORIZED, UnauthorizedException),
+        (HTTPStatus.BAD_REQUEST, BadRequestException),
+        (HTTPStatus.NOT_FOUND, NotFoundException),
+        (HTTPStatus.GATEWAY_TIMEOUT, UnknownException),
+    ],
+)
+def test_http_status_exception(status, expected_type):
+    class MockResponse:
+        status_code = status
+
+        def json(self) -> Any:
+            return {
+                "exception": "com.linecorp.centraldogma.common.unknown",
+                "message": "foobar",
+            }
+
+        def text(self) -> str:
+            return json.dumps(self.json())
+
+    # noinspection PyTypeChecker
+    exception = to_exception(MockResponse())
+    assert isinstance(exception, expected_type)
+    assert str(exception) == "foobar"
+
+
+def test_invalid_response_exception():
+    class MockResponse:
+        def json(self) -> Any:
+            return json.loads(self.text())
+
+        def text(self):
+            return '{"foo":'
+
+    # noinspection PyTypeChecker
+    exception: InvalidResponseException = to_exception(MockResponse())
+    assert isinstance(exception, InvalidResponseException)