Skip to content
7 changes: 7 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
## Changes in version 0.0.5 (in development)


- In `s2gos_client`:
- Improved error handling (#54)
- Renamed `ClientException` into `ClientError`.
- Renamed `TransportException` into `TransportError`.


## Changes in version 0.0.4

- Prevent server from logging `/jobs` request, as they are used for polling.
Expand All @@ -12,6 +18,7 @@
- Added a keyword-argument `inputs_arg: str | bool` to the `ProcessRegistry.process`
decorator. If specified, it defines an _inputs argument_, which is used to
define the process inputs in form of a dataclass derived from
-
`pydantic.BaseModel`. (#35)
- Now supporting the OpenAPI/JSON Schema `discriminator` property
in `tools/openapi.yaml/schemas/schema` and generated models. (#36)
Expand Down
4 changes: 2 additions & 2 deletions docs/client-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ Both clients return their configuration as a
[`ClientConfig`](#s2gos_client.ClientConfig) object.

Methods of the [`Client`](#s2gos_client.Client) and `AsyncClient`
may raise a [`ClientException`](#s2gos_client.ClientException) if a server call fails.
may raise a [`ClientError`](#s2gos_client.ClientError) if a server call fails.

::: s2gos_client.Client

::: s2gos_client.ClientConfig

::: s2gos_client.ClientException
::: s2gos_client.ClientError
11 changes: 11 additions & 0 deletions docs/client-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ It herewith resembles the functionality of the OGC API Processes - Part 1.
You can use shorter command name aliases, e.g., use command name `vr`
for `validate-request`, or `lp` for `list-processes`.

The tool's exit codes are as follows:

- `0` - normal exit
- `1` - user errors, argument errors
- `2` - remote API errors
- `3` - local network transport errors

If the --traceback flag is set, the original Python exception traceback
will be shown and the exit code will always be `1`.
Otherwise, only the error message is shown.

**Usage**:

```console
Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ The output of `generators/gen_models` is not satisfying:

* **DONE**: Include server traceback on internal server errors with 500 status
* **DONE**: We currently have only little error management in client.
Handle ClientException so users understand what went wrong:
Handle ClientError so users understand what went wrong:
- **DONE**: Python API
- **DONE**: CLI
- **DONE**: GUI
Expand Down
4 changes: 2 additions & 2 deletions s2gos-client/src/s2gos_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
from .api.async_client import AsyncClient
from .api.client import Client
from .api.config import ClientConfig
from .api.exceptions import ClientException
from .api.exceptions import ClientError

__version__ = version("s2gos-client")

__all__ = [
"AsyncClient",
"Client",
"ClientConfig",
"ClientException",
"ClientError",
"__version__",
]
12 changes: 12 additions & 0 deletions s2gos-client/src/s2gos_client/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
# Copyright (c) 2025 by ESA DTE-S2GOS team and contributors
# Permissions are hereby granted under the terms of the Apache 2.0 License:
# https://opensource.org/license/apache-2-0.

from .async_client import AsyncClient
from .client import Client
from .config import ClientConfig
from .exceptions import ClientError

__all__ = [
"AsyncClient",
"Client",
"ClientConfig",
"ClientError",
]
2 changes: 1 addition & 1 deletion s2gos-client/src/s2gos_client/api/async_client_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ async def create_execution_request(
The execution request template.

Raises:
ClientException: if an error occurs
ClientError: if an error occurs
"""
process_description = await self.get_process(process_id)
return ExecutionRequest.from_process_description(
Expand Down
2 changes: 1 addition & 1 deletion s2gos-client/src/s2gos_client/api/client_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def create_execution_request(
The execution request template.

Raises:
ClientException: if an error occurs
ClientError: if an error occurs
"""
process_description = self.get_process(process_id)
return ExecutionRequest.from_process_description(
Expand Down
2 changes: 1 addition & 1 deletion s2gos-client/src/s2gos_client/api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from s2gos_common.models import ApiError


class ClientException(Exception):
class ClientError(Exception):
"""Raised if a web API call failed.

The failure can have several reasons such as
Expand Down
6 changes: 3 additions & 3 deletions s2gos-client/src/s2gos_client/api/ishell.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ def _register_exception_handler() -> Callable[[Any, Any, Any, Any], None]:
from IPython.core.interactiveshell import InteractiveShell
from IPython.display import JSON, display

from .exceptions import ClientException
from .exceptions import ClientError

# noinspection PyUnusedLocal
def handle_exception(
self: InteractiveShell, exc_type, exc_value, tb, tb_offset=None
):
if isinstance(exc_value, ClientException):
if isinstance(exc_value, ClientError):
display(
JSON(
exc_value.api_error.model_dump(mode="json", exclude_none=True),
Expand All @@ -34,7 +34,7 @@ def handle_exception(
return None

# Register handler for MyCustomError
InteractiveShell.instance().set_custom_exc((ClientException,), handle_exception)
InteractiveShell.instance().set_custom_exc((ClientError,), handle_exception)
return handle_exception


Expand Down
4 changes: 2 additions & 2 deletions s2gos-client/src/s2gos_client/api/transport/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
# https://opensource.org/license/apache-2-0.

from .args import TransportArgs
from .transport import AsyncTransport, Transport
from .transport import AsyncTransport, Transport, TransportError

__all__ = ["AsyncTransport", "Transport", "TransportArgs"]
__all__ = ["AsyncTransport", "Transport", "TransportArgs", "TransportError"]
6 changes: 3 additions & 3 deletions s2gos-client/src/s2gos_client/api/transport/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import uri_template
from pydantic import BaseModel

from s2gos_client.api.exceptions import ClientException
from s2gos_client.api.exceptions import ClientError
from s2gos_common.models import ApiError


Expand Down Expand Up @@ -58,7 +58,7 @@ def get_exception_for_status(
status_code: int,
message: str,
json_data: Optional[Any] = None,
) -> ClientException:
) -> ClientError:
status_key = str(status_code)
# Currently, all error types fall back to ApiError
_return_type = self.error_types.get(status_key)
Expand All @@ -77,4 +77,4 @@ def get_exception_for_status(
title="Missing error details from API",
detail=f"JSON object expected, but got {type(json_data).__name__}",
)
return ClientException(f"{message} (status {status_code})", api_error=api_error)
return ClientError(f"{message} (status {status_code})", api_error=api_error)
16 changes: 11 additions & 5 deletions s2gos-client/src/s2gos_client/api/transport/httpx.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@

import httpx

from s2gos_client.api.exceptions import ClientException
from s2gos_client.api.exceptions import ClientError
from s2gos_common.models import ApiError

from .args import TransportArgs
from .transport import AsyncTransport, Transport
from .transport import AsyncTransport, Transport, TransportError


class HttpxTransport(Transport, AsyncTransport):
Expand All @@ -32,14 +32,20 @@ def call(self, args: TransportArgs) -> Any:
if self.sync_httpx is None:
self.sync_httpx = httpx.Client()
args_, kwargs_ = self._get_request_args(args)
response = self.sync_httpx.request(*args_, **kwargs_)
try:
response = self.sync_httpx.request(*args_, **kwargs_)
except httpx.HTTPError as e:
raise TransportError(f"{e}") from e
return self._process_response(args, response)

async def async_call(self, args: TransportArgs) -> Any:
if self.async_httpx is None:
self.async_httpx = httpx.AsyncClient()
args_, kwargs_ = self._get_request_args(args)
response = await self.async_httpx.request(*args_, **kwargs_)
try:
response = await self.async_httpx.request(*args_, **kwargs_)
except httpx.HTTPError as e:
raise TransportError(f"{e}") from e
return self._process_response(args, response)

def _get_request_args(
Expand All @@ -60,7 +66,7 @@ def _process_response(self, args: TransportArgs, response: httpx.Response) -> An
# use args.return_types for this decision.
response_json = response.json()
except (ValueError, TypeError) as e:
raise ClientException(
raise ClientError(
f"{e}",
api_error=ApiError(
type=type(e).__name__,
Expand Down
8 changes: 6 additions & 2 deletions s2gos-client/src/s2gos_client/api/transport/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def call(self, args: TransportArgs) -> Any:
The response data of a successful web API call.

Raises:
TransportException: If the web API call failed.
TransportError: If an attempt to reach the server failed.
"""

def close(self):
Expand All @@ -51,8 +51,12 @@ async def async_call(self, args: TransportArgs) -> Any:
The response data of a successful web API call.

Raises:
TransportException: If the web API call failed.
TransportError: If an attempt to reach the server failed.
"""

async def async_close(self):
"""Closes this transport."""


class TransportError(Exception):
"""Raised if an attempt to reach the server failed."""
11 changes: 11 additions & 0 deletions s2gos-client/src/s2gos_client/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@

You can use shorter command name aliases, e.g., use command name `vr`
for `validate-request`, or `lp` for `list-processes`.

The tool's exit codes are as follows:

- `0` - normal exit
- `1` - user errors, argument errors
- `2` - remote API errors
- `3` - local network transport errors

If the --traceback flag is set, the original Python exception traceback
will be shown and the exit code will always be `1`.
Otherwise, only the error message is shown.
""".format(app_name=CLI_NAME, service_name=SERVICE_NAME)

DEFAULT_OUTPUT_FORMAT: Final = OutputFormat.yaml
Expand Down
23 changes: 16 additions & 7 deletions s2gos-client/src/s2gos_client/cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
from types import TracebackType
from typing import Callable, Literal, Optional, TypeAlias

import click
import typer

from s2gos_client.api.client import Client
from s2gos_client.api.exceptions import ClientException
from s2gos_client.api.exceptions import ClientError
from s2gos_client.api.transport import TransportError

GetClient: TypeAlias = Callable[[str | None], Client]

Expand Down Expand Up @@ -42,11 +42,13 @@ def __exit__(
self.client.close()
self.client = None
show_traceback = self.ctx.obj.get("traceback", False)
if isinstance(exc_value, ClientException):
client_error: ClientException = exc_value
if isinstance(exc_value, ClientError):
# Note for the following it may be a good idea to
# to use rich.traceback for comprehensive output
client_error: ClientError = exc_value
api_error = client_error.api_error
message_lines = [
f"{client_error}",
f"❌ Error: {client_error}",
"Server-side error details:",
f" title: {api_error.title}",
f" status: {api_error.status}",
Expand All @@ -56,5 +58,12 @@ def __exit__(
if api_error.traceback and show_traceback:
message_lines.append(" traceback:")
message_lines.extend(api_error.traceback)
raise click.ClickException("\n".join(message_lines))
return False
typer.echo("\n".join(message_lines))
if not show_traceback:
raise typer.Exit(code=2)
elif isinstance(exc_value, TransportError):
typer.echo(f"❌ Transport error: {exc_value}")
if not show_traceback:
raise typer.Exit(code=3)

return False # propagate exception
8 changes: 4 additions & 4 deletions s2gos-client/src/s2gos_client/gui/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from typing import Optional

from s2gos_client.api.client import Client as ApiClient
from s2gos_client.api.exceptions import ClientException
from s2gos_client.api.exceptions import ClientError
from s2gos_client.api.transport import Transport
from s2gos_common.models import JobInfo, ProcessList

Expand Down Expand Up @@ -120,7 +120,7 @@ def _run_jobs_updater(self):
def _update_jobs(self):
try:
job_list = self.get_jobs()
except ClientException as e:
except ClientError as e:
for jobs_observer in self._jobs_observers:
jobs_observer.on_job_list_error(e)
return
Expand Down Expand Up @@ -149,8 +149,8 @@ def _update_jobs(self):
jobs_observer.on_job_list_changed(job_list)
self._jobs = new_jobs

def _get_processes(self) -> tuple[ProcessList, ClientException | None]:
def _get_processes(self) -> tuple[ProcessList, ClientError | None]:
try:
return self.get_processes(), None
except ClientException as e:
except ClientError as e:
return ProcessList(processes=[], links=[]), e
6 changes: 3 additions & 3 deletions s2gos-client/src/s2gos_client/gui/job_info_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import panel as pn
import param

from s2gos_client import ClientException
from s2gos_client import ClientError
from s2gos_common.models import JobInfo, JobList

from .jobs_observer import JobsObserver
Expand All @@ -17,7 +17,7 @@
class JobInfoPanel(pn.viewable.Viewer):
job_info = param.ClassSelector(class_=JobInfo, allow_None=True, default=None)
client_error = param.ClassSelector(
class_=ClientException, allow_None=True, default=None
class_=ClientError, allow_None=True, default=None
)

def __init__(self):
Expand Down Expand Up @@ -47,7 +47,7 @@ def on_job_list_changed(self, job_list: JobList):
# Nothing to do
pass

def on_job_list_error(self, client_error: ClientException | None):
def on_job_list_error(self, client_error: ClientError | None):
self.client_error = client_error

def _is_observed_job(self, job_info):
Expand Down
4 changes: 2 additions & 2 deletions s2gos-client/src/s2gos_client/gui/jobs_observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from abc import ABC, abstractmethod

from s2gos_client import ClientException
from s2gos_client import ClientError
from s2gos_common.models import JobInfo, JobList


Expand All @@ -28,5 +28,5 @@ def on_job_list_changed(self, job_list: JobList):
"""Called after the current list of jobs changed."""

@abstractmethod
def on_job_list_error(self, error: ClientException | None):
def on_job_list_error(self, error: ClientError | None):
"""Called if an error occurred while getting current list of jobs."""
Loading