Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ Errors you return on purpose, with `Error`, `InvalidParams` or by raising
clients can't parse anything else. The response is still sent as before.
- `@method` gives a `UserWarning` for a name that starts with `rpc.`, because
the spec reserves those names.
- A method that returns a plain value instead of `Success(value)` is a common
mistake when upgrading from 4.x. It's now logged with a message that says
what to change, instead of a traceback. The client still gets an Internal
error.
- `serve()` says where it's listening when it starts, including a note when it
listens on every network interface (the default). It logs the line on the
`jsonrpcserver.server` logger, and writes it to stderr if logging isn't
configured.
- Every public function and class has a full docstring, which the new API
reference on the docs site is built from.

### Behaviour changes

Expand Down
5 changes: 5 additions & 0 deletions jsonrpcserver/async_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
NORESPONSE,
BatchTooLargeResponse,
Deserialized,
InvalidResultError,
create_request,
deserialize_request,
exception_data,
extract_args,
extract_kwargs,
extract_list,
get_method,
log_invalid_result,
member_id,
not_notification,
to_response,
Expand Down Expand Up @@ -52,6 +54,9 @@ async def call(
validate_result(result)
except JsonRpcError as exc:
return Left(ErrorResult(code=exc.code, message=exc.message, data=exc.data))
except InvalidResultError as exc:
log_invalid_result(request.method, exc, logger)
return Left(InternalErrorResult(exception_data(exc, debug)))
except Exception as exc:
# Other error inside method - Internal error
logger.exception("Method %r raised an exception", request.method)
Expand Down
85 changes: 84 additions & 1 deletion jsonrpcserver/async_main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
"""Async version of main.py. The public async functions."""
"""The async dispatch functions, for asyncio servers.

They take the same arguments as the functions in main.py, and are imported from
the package as async_dispatch, async_dispatch_to_serializable and
async_dispatch_to_response. Methods can be async functions or plain ones. The
requests in a batch run concurrently.
"""

from typing import Any, Callable, Dict, Iterable, List, Optional, Union, cast

Expand Down Expand Up @@ -30,6 +36,30 @@ async def dispatch_to_response(
debug: bool = False,
max_batch_size: Optional[int] = None,
) -> Union[Response, Iterable[Response], None]:
"""Dispatch a request and give the response as Response objects. Async.

The async version of `dispatch_to_response`, imported from the package as
`async_dispatch_to_response`. It takes the same arguments.

Args:
request: The JSON-RPC request string.
methods: The same as for `dispatch_to_response`.
context: The same as for `dispatch_to_response`.
deserializer: The same as for `dispatch_to_response`.
validator: The same as for `dispatch_to_response`.
post_process: The same as for `dispatch_to_response`.
debug: The same as for `dispatch_to_response`.
max_batch_size: The same as for `dispatch_to_response`. Every request in a
batch runs at the same time, so a limit matters even more here.

Returns:
A Response for a single request, a list of Responses for a batch, or None
if there's nothing to send back. The type hint says Iterable for a
batch, but it's a list.

Raises:
ValueError: If `max_batch_size` isn't None or a positive int.
"""
check_max_batch_size(max_batch_size)
response = await dispatch_to_response_pure(
deserializer=deserializer,
Expand All @@ -54,6 +84,27 @@ async def dispatch_to_serializable(
debug: bool = False,
max_batch_size: Optional[int] = None,
) -> Union[Dict[str, Any], List[Dict[str, Any]], None]:
"""Dispatch a request and give the response as a dict. Async.

The async version of `dispatch_to_serializable`, imported from the package as
`async_dispatch_to_serializable`.

Args:
request: The JSON-RPC request string.
methods: The same as for `dispatch_to_response`.
context: The same as for `dispatch_to_response`.
deserializer: The same as for `dispatch_to_response`.
validator: The same as for `dispatch_to_response`.
debug: The same as for `dispatch_to_response`.
max_batch_size: The same as for `dispatch_to_response`.

Returns:
The response as a dict, a list of dicts for a batch, or None if there's
nothing to send back.

Raises:
ValueError: If `max_batch_size` isn't None or a positive int.
"""
return cast(
Union[Dict[str, Any], List[Dict[str, Any]], None],
await dispatch_to_response(
Expand Down Expand Up @@ -82,6 +133,37 @@ async def dispatch_to_json(
[Union[Dict[str, Any], List[Dict[str, Any]], None]], str
] = default_serializer,
) -> str:
"""Dispatch a request and give the response as a JSON string. Async.

This is `async_dispatch`, the async version of `dispatch`. Methods can be async
functions or plain ones (plain ones work from 5.0.10). A plain method runs on
the event loop, so a slow one holds up every other request.

Args:
request: The JSON-RPC request string.
methods: The same as for `dispatch_to_response`.
context: The same as for `dispatch_to_response`.
deserializer: The same as for `dispatch_to_response`.
validator: The same as for `dispatch_to_response`.
debug: The same as for `dispatch_to_response`.
max_batch_size: The same as for `dispatch_to_response`.
serializer: The same as for `dispatch`.

Returns:
The response as a JSON string, or "" if there's nothing to send back.

Raises:
ValueError: If `max_batch_size` isn't None or a positive int.

Example:
>>> import asyncio
>>> from jsonrpcserver import Result, Success, async_dispatch
>>> async def ping() -> Result:
... return Success("pong")
>>> request = '{"jsonrpc": "2.0", "method": "ping", "id": 1}'
>>> asyncio.run(async_dispatch(request, {"ping": ping}))
'{"jsonrpc": "2.0", "result": "pong", "id": 1}'
"""
response = await dispatch_to_serializable(
request,
methods,
Expand All @@ -95,3 +177,4 @@ async def dispatch_to_json(


dispatch = dispatch_to_json
"""Another name for `dispatch_to_json`. The package exports it as `async_dispatch`."""
14 changes: 13 additions & 1 deletion jsonrpcserver/codes.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
"""JSONRPC error codes from http://www.jsonrpc.org/specification#error_object"""
"""The error codes jsonrpcserver sends.

The first five are defined by the spec:
https://www.jsonrpc.org/specification#error_object
"""

ERROR_PARSE_ERROR = -32700
"""The request isn't valid JSON (or the deserializer raised)."""
ERROR_INVALID_REQUEST = -32600
"""The JSON isn't a valid request object, or a batch is empty or too big."""
ERROR_METHOD_NOT_FOUND = -32601
"""No method has that name."""
ERROR_INVALID_PARAMS = -32602
"""The params don't fit the method's signature, or the method said they're invalid."""
ERROR_INTERNAL_ERROR = -32603
"""The method raised, returned something other than a Result, or its result
could not be serialized.
"""
ERROR_SERVER_ERROR = -32000
"""Something failed in jsonrpcserver itself, outside any method."""
41 changes: 37 additions & 4 deletions jsonrpcserver/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import logging
import reprlib
from functools import partial
from inspect import iscoroutine, signature
from itertools import starmap
Expand Down Expand Up @@ -112,23 +113,52 @@ def extract_kwargs(request: Request) -> Dict[str, Any]:
return request.params if isinstance(request.params, dict) else {}


class InvalidResultError(AssertionError):
"""A method returned something other than Success(...) or Error(...)."""

def __init__(self, result: object):
super().__init__(
f"The method did not return a valid Result (returned {result!r}). "
"Return Success(value) or Error(code, message)."
)
self.result = result


def validate_result(result: object) -> None:
"""Validate the return value from a method.

Raises an AssertionError if the result returned from a method is invalid.
Raises an InvalidResultError, a subclass of AssertionError, if the result returned
from a method is invalid.

Returns: None
"""
# Not an assert statement, because python -O would remove it.
returned = result # Not narrowed by the checks below.
error: object = getattr(result, "_error", None)
value: object = getattr(result, "_value", None)
if not (
(isinstance(result, Left) and isinstance(error, ErrorResult))
or (isinstance(result, Right) and isinstance(value, SuccessResult))
):
raise AssertionError(
f"The method did not return a valid Result (returned {result!r})"
)
raise InvalidResultError(returned)


def log_invalid_result(
method_name: str, exc: InvalidResultError, log: logging.Logger
) -> None:
"""Log a method that returned a plain value, which 4.x allowed, without a traceback.

The traceback would only point into jsonrpcserver, so the message says what to
change instead.
"""
log.error(
"Method %r returned %s, which is not a Result, so the client got an "
"Internal error. Return Success(value) or Error(code, message). Since 5.0 a "
"plain return value is not enough: "
"https://bensynapse.github.io/jsonrpcserver/migration/",
method_name,
reprlib.repr(exc.result),
)


def exception_data(exc: BaseException, debug: bool) -> Any:
Expand Down Expand Up @@ -166,6 +196,9 @@ def call(
# response.
except JsonRpcError as exc:
return Left(ErrorResult(code=exc.code, message=exc.message, data=exc.data))
except InvalidResultError as exc:
log_invalid_result(request.method, exc, logger)
return Left(InternalErrorResult(exception_data(exc, debug)))
# Any other uncaught exception inside method - internal error.
except Exception as exc:
logger.exception("Method %r raised an exception", request.method)
Expand Down
26 changes: 22 additions & 4 deletions jsonrpcserver/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Exceptions"""
"""The exception a method can raise to send an error response."""

from typing import Any

Expand All @@ -7,9 +7,27 @@


class JsonRpcError(Exception):
"""A JsonRpcError exception can be raised from inside a method, as an alternate way
to return an error response. See
https://bensynapse.github.io/jsonrpcserver/methods/#results
"""Raise it in a method, or in anything a method calls, to send an error response.

It's the same as returning `Error(code, message, data)`, but it works from deep
inside other functions. Like `Error`, it's sent as it is, whatever `debug` is.

Args:
code: The error code, an integer.
message: A short description of the error, as a string.
data: Extra information for the client. If it isn't given, the response has
no `data` member.

Warns:
UserWarning: If `code` isn't an int or `message` isn't a str. New in 5.0.10.

Example:
>>> from jsonrpcserver import Result, dispatch_to_serializable
>>> def withdraw(amount: int) -> Result:
... raise JsonRpcError(2, "Insufficient funds", {"balance": 10})
>>> request = '{"jsonrpc": "2.0", "method": "withdraw", "params": [5], "id": 1}'
>>> dispatch_to_serializable(request, {"withdraw": withdraw})["error"]
{'code': 2, 'message': 'Insufficient funds', 'data': {'balance': 10}}
"""

def __init__(self, code: int, message: str, data: Any = NODATA):
Expand Down
Loading