fix error handling in jsonrpc

This commit is contained in:
Alex Grintsvayg 2017-03-22 20:27:01 -04:00
parent 41fbb1399c
commit 25d9f008de
5 changed files with 256 additions and 67 deletions

View file

@ -20,11 +20,10 @@ from lbrynet.core.sqlite_helpers import rerun_if_locked
from lbrynet.interfaces import IRequestCreator, IQueryHandlerFactory, IQueryHandler, IWallet from lbrynet.interfaces import IRequestCreator, IQueryHandlerFactory, IQueryHandler, IWallet
from lbrynet.core.client.ClientRequest import ClientRequest from lbrynet.core.client.ClientRequest import ClientRequest
from lbrynet.core.Error import (UnknownNameError, InvalidStreamInfoError, RequestCanceledError, from lbrynet.core.Error import (UnknownNameError, InvalidStreamInfoError, RequestCanceledError,
InsufficientFundsError) InsufficientFundsError)
from lbrynet.db_migrator.migrate1to2 import UNSET_NOUT from lbrynet.db_migrator.migrate1to2 import UNSET_NOUT
from lbrynet.metadata.Metadata import Metadata from lbrynet.metadata.Metadata import Metadata
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -1030,13 +1029,15 @@ class LBRYumWallet(Wallet):
d.addCallback(lambda claim_out: self._broadcast_claim_transaction(claim_out)) d.addCallback(lambda claim_out: self._broadcast_claim_transaction(claim_out))
return d return d
@defer.inlineCallbacks
def _abandon_claim(self, claim_outpoint): def _abandon_claim(self, claim_outpoint):
log.debug("Abandon %s %s" % (claim_outpoint['txid'], claim_outpoint['nout'])) log.debug("Abandon %s %s" % (claim_outpoint['txid'], claim_outpoint['nout']))
broadcast = False broadcast = False
d = self._run_cmd_as_defer_succeed('abandon', claim_outpoint['txid'], claim_out = yield self._run_cmd_as_defer_succeed(
claim_outpoint['nout'], broadcast) 'abandon', claim_outpoint['txid'], claim_outpoint['nout'], broadcast
d.addCallback(lambda claim_out: self._broadcast_claim_transaction(claim_out)) )
return d yield self._broadcast_claim_transaction(claim_out)
defer.returnValue()
def _support_claim(self, name, claim_id, amount): def _support_claim(self, name, claim_id, amount):
log.debug("Support %s %s %f" % (name, claim_id, amount)) log.debug("Support %s %s %f" % (name, claim_id, amount))

View file

@ -1445,8 +1445,8 @@ class Daemon(AuthJSONRPCServer):
@AuthJSONRPCServer.auth_required @AuthJSONRPCServer.auth_required
@defer.inlineCallbacks @defer.inlineCallbacks
def jsonrpc_get( def jsonrpc_get(
self, name, file_name=None, stream_info=None, timeout=None, self, name, file_name=None, stream_info=None, timeout=None,
download_directory=None, wait_for_write=True): download_directory=None, wait_for_write=True):
""" """
Download stream from a LBRY name. Download stream from a LBRY name.
@ -1632,8 +1632,6 @@ class Daemon(AuthJSONRPCServer):
cost = yield self.get_est_cost(name, size) cost = yield self.get_est_cost(name, size)
defer.returnValue(cost) defer.returnValue(cost)
@AuthJSONRPCServer.auth_required @AuthJSONRPCServer.auth_required
@defer.inlineCallbacks @defer.inlineCallbacks
def jsonrpc_publish(self, name, bid, metadata=None, file_path=None, fee=None, title=None, def jsonrpc_publish(self, name, bid, metadata=None, file_path=None, fee=None, title=None,
@ -1725,7 +1723,6 @@ class Daemon(AuthJSONRPCServer):
metadata['fee'][currency]['address'] = new_address metadata['fee'][currency]['address'] = new_address
metadata['fee'] = FeeValidator(metadata['fee']) metadata['fee'] = FeeValidator(metadata['fee'])
log.info("Publish: %s", { log.info("Publish: %s", {
'name': name, 'name': name,
'file_path': file_path, 'file_path': file_path,
@ -1765,9 +1762,12 @@ class Daemon(AuthJSONRPCServer):
try: try:
abandon_claim_tx = yield self.session.wallet.abandon_claim(txid, nout) abandon_claim_tx = yield self.session.wallet.abandon_claim(txid, nout)
response = yield self._render_response(abandon_claim_tx) response = yield self._render_response(abandon_claim_tx)
except Exception as err: except BaseException as err:
log.warning(err) log.warning(err)
response = yield self._render_response(err) if len(err.args) and err.args[0] == "txid was not found in wallet":
raise Exception("This transaction was not found in your wallet")
else:
response = yield self._render_response(err)
defer.returnValue(response) defer.returnValue(response)
@AuthJSONRPCServer.auth_required @AuthJSONRPCServer.auth_required
@ -2113,7 +2113,6 @@ class Daemon(AuthJSONRPCServer):
""" """
return self.jsonrpc_blob_get(sd_hash, timeout, 'json', payment_rate_manager) return self.jsonrpc_blob_get(sd_hash, timeout, 'json', payment_rate_manager)
@AuthJSONRPCServer.auth_required @AuthJSONRPCServer.auth_required
@defer.inlineCallbacks @defer.inlineCallbacks
def jsonrpc_blob_get(self, blob_hash, timeout=None, encoding=None, payment_rate_manager=None): def jsonrpc_blob_get(self, blob_hash, timeout=None, encoding=None, payment_rate_manager=None):
@ -2639,6 +2638,3 @@ def format_json_out_amount_as_float(obj):
elif isinstance(obj, list): elif isinstance(obj, list):
obj = [format_json_out_amount_as_float(o) for o in obj] obj = [format_json_out_amount_as_float(o) for o in obj]
return obj return obj

View file

@ -1,5 +1,6 @@
import logging import logging
import urlparse import urlparse
import inspect
from decimal import Decimal from decimal import Decimal
from zope.interface import implements from zope.interface import implements
@ -8,10 +9,12 @@ from twisted.internet import defer
from twisted.python.failure import Failure from twisted.python.failure import Failure
from twisted.internet.error import ConnectionDone, ConnectionLost from twisted.internet.error import ConnectionDone, ConnectionLost
from txjsonrpc import jsonrpclib from txjsonrpc import jsonrpclib
from traceback import format_exc
from lbrynet import conf from lbrynet import conf
from lbrynet.core.Error import InvalidAuthenticationToken from lbrynet.core.Error import InvalidAuthenticationToken
from lbrynet.core import utils from lbrynet.core import utils
from lbrynet.undecorated import undecorated
from lbrynet.lbrynet_daemon.auth.util import APIKey, get_auth_message, jsonrpc_dumps_pretty from lbrynet.lbrynet_daemon.auth.util import APIKey, get_auth_message, jsonrpc_dumps_pretty
from lbrynet.lbrynet_daemon.auth.client import LBRY_SECRET from lbrynet.lbrynet_daemon.auth.client import LBRY_SECRET
@ -20,24 +23,65 @@ log = logging.getLogger(__name__)
EMPTY_PARAMS = [{}] EMPTY_PARAMS = [{}]
class JSONRPCError(object):
# http://www.jsonrpc.org/specification#error_object
CODE_PARSE_ERROR = -32700 # Invalid JSON. Error while parsing the JSON text.
CODE_INVALID_REQUEST = -32600 # The JSON sent is not a valid Request object.
CODE_METHOD_NOT_FOUND = -32601 # The method does not exist / is not available.
CODE_INVALID_PARAMS = -32602 # Invalid method parameter(s).
CODE_INTERNAL_ERROR = -32603 # Internal JSON-RPC error (I think this is like a 500?)
CODE_APPLICATION_ERROR = -32500 # Generic error with our app??
CODE_AUTHENTICATION_ERROR = -32501 # Authentication failed
MESSAGES = {
CODE_PARSE_ERROR: "Parse Error. Data is not valid JSON.",
CODE_INVALID_REQUEST: "JSON data is not a valid Request",
CODE_METHOD_NOT_FOUND: "Method Not Found",
CODE_INVALID_PARAMS: "Invalid Params",
CODE_INTERNAL_ERROR: "Internal Error",
CODE_AUTHENTICATION_ERROR: "Authentication Failed",
}
HTTP_CODES = {
CODE_INVALID_REQUEST: 400,
CODE_PARSE_ERROR: 400,
CODE_INVALID_PARAMS: 400,
CODE_METHOD_NOT_FOUND: 404,
CODE_INTERNAL_ERROR: 500,
CODE_APPLICATION_ERROR: 500,
CODE_AUTHENTICATION_ERROR: 401,
}
def __init__(self, message, code=CODE_APPLICATION_ERROR, traceback=None, data=None):
assert isinstance(code, (int, long)), "'code' must be an int"
assert (data is None or isinstance(data, dict)), "'data' must be None or a dict"
self.code = code
if message is None:
message = self.MESSAGES[code] if code in self.MESSAGES else "Error"
self.message = message
self.data = {} if data is None else data
if traceback is not None:
self.data['traceback'] = traceback.split("\n")
def to_dict(self):
ret = {
'code': self.code,
'message': self.message,
}
if len(self.data):
ret['data'] = self.data
return ret
@classmethod
def create_from_exception(cls, exception, code=CODE_APPLICATION_ERROR, traceback=None):
return cls(exception.message, code=code, traceback=traceback)
def default_decimal(obj): def default_decimal(obj):
if isinstance(obj, Decimal): if isinstance(obj, Decimal):
return float(obj) return float(obj)
class JSONRPCException(Exception):
def __init__(self, err, code):
self.faultCode = code
self.err = err
@property
def faultString(self):
try:
return self.err.getTraceback()
except AttributeError:
return str(self.err)
class UnknownAPIMethodError(Exception): class UnknownAPIMethodError(Exception):
pass pass
@ -93,11 +137,6 @@ class AuthJSONRPCServer(AuthorizedBase):
implements(resource.IResource) implements(resource.IResource)
isLeaf = True isLeaf = True
OK = 200
UNAUTHORIZED = 401
# TODO: codes should follow jsonrpc spec: http://www.jsonrpc.org/specification#error_object
NOT_FOUND = 8001
FAILURE = 8002
def __init__(self, use_authentication=None): def __init__(self, use_authentication=None):
AuthorizedBase.__init__(self) AuthorizedBase.__init__(self)
@ -121,30 +160,51 @@ class AuthJSONRPCServer(AuthorizedBase):
session_id = request.getSession().uid session_id = request.getSession().uid
request.setHeader(LBRY_SECRET, self.sessions.get(session_id).secret) request.setHeader(LBRY_SECRET, self.sessions.get(session_id).secret)
def _render_message(self, request, message): @staticmethod
def _render_message(request, message):
request.write(message) request.write(message)
request.finish() request.finish()
def _render_error(self, failure, request, id_, def _render_error(self, failure, request, id_, version=jsonrpclib.VERSION_2):
version=jsonrpclib.VERSION_2, response_code=FAILURE): if isinstance(failure, JSONRPCError):
# TODO: is it necessary to wrap the failure in a Failure? if not, merge this with next fn error = failure
self._render_error_string(Failure(failure), request, id_, version, response_code) elif isinstance(failure, Failure):
# maybe failure is JSONRPCError wrapped in a twisted Failure
error = failure.check(JSONRPCError)
if error is None:
# maybe its a twisted Failure with another type of error
error = JSONRPCError(failure.getErrorMessage(), traceback=failure.getTraceback())
else:
# last resort, just cast it as a string
error = JSONRPCError(str(failure))
def _render_error_string(self, error_string, request, id_, version=jsonrpclib.VERSION_2, response_content = jsonrpc_dumps_pretty(
response_code=FAILURE): error.to_dict(), id=id_, version=version, sort_keys=False
err = JSONRPCException(error_string, response_code) )
fault = jsonrpc_dumps_pretty(err, id=id_, version=version)
self._set_headers(request, fault)
if response_code != AuthJSONRPCServer.FAILURE:
request.setResponseCode(response_code)
self._render_message(request, fault)
def _handle_dropped_request(self, result, d, function_name): self._set_headers(request, response_content)
try:
request.setResponseCode(JSONRPCError.HTTP_CODES[error.code])
except KeyError:
request.setResponseCode(JSONRPCError.HTTP_CODES[JSONRPCError.CODE_INTERNAL_ERROR])
self._render_message(request, response_content)
@staticmethod
def _handle_dropped_request(result, d, function_name):
if not d.called: if not d.called:
log.warning("Cancelling dropped api request %s", function_name) log.warning("Cancelling dropped api request %s", function_name)
d.cancel() d.cancel()
def render(self, request): def render(self, request):
try:
return self._render(request)
except BaseException as e:
log.error(e)
error = JSONRPCError.create_from_exception(e, traceback=format_exc())
self._render_error(error, request, None)
return server.NOT_DONE_YET
def _render(self, request):
time_in = utils.now() time_in = utils.now()
# assert self._check_headers(request), InvalidHeaderError # assert self._check_headers(request), InvalidHeaderError
session = request.getSession() session = request.getSession()
@ -161,7 +221,7 @@ class AuthJSONRPCServer(AuthorizedBase):
session.startCheckingExpiration() session.startCheckingExpiration()
session.notifyOnExpire(expire_session) session.notifyOnExpire(expire_session)
message = "OK" message = "OK"
request.setResponseCode(self.OK) request.setResponseCode(200)
self._set_headers(request, message, True) self._set_headers(request, message, True)
self._render_message(request, message) self._render_message(request, message)
return server.NOT_DONE_YET return server.NOT_DONE_YET
@ -174,14 +234,24 @@ class AuthJSONRPCServer(AuthorizedBase):
parsed = jsonrpclib.loads(content) parsed = jsonrpclib.loads(content)
except ValueError: except ValueError:
log.warning("Unable to decode request json") log.warning("Unable to decode request json")
self._render_error_string('Invalid JSON', request, None) self._render_error(JSONRPCError(None, JSONRPCError.CODE_PARSE_ERROR), request, None)
return server.NOT_DONE_YET return server.NOT_DONE_YET
function_name = parsed.get('method') id_ = None
args = parsed.get('params', {}) version = jsonrpclib.VERSION_2
id_ = parsed.get('id') try:
token = parsed.pop('hmac', None) function_name = parsed.get('method')
version = self._get_jsonrpc_version(parsed.get('jsonrpc'), id_) args = parsed.get('params', {})
id_ = parsed.get('id', None)
version = self._get_jsonrpc_version(parsed.get('jsonrpc'), id_)
token = parsed.pop('hmac', None)
except AttributeError as err:
log.warning(err)
self._render_error(
JSONRPCError(None, code=JSONRPCError.CODE_INVALID_REQUEST),
request, id_, version=version
)
return server.NOT_DONE_YET
reply_with_next_secret = False reply_with_next_secret = False
if self._use_authentication: if self._use_authentication:
@ -191,31 +261,60 @@ class AuthJSONRPCServer(AuthorizedBase):
except InvalidAuthenticationToken as err: except InvalidAuthenticationToken as err:
log.warning("API validation failed") log.warning("API validation failed")
self._render_error( self._render_error(
err, request, id_, version=version, JSONRPCError.create_from_exception(
response_code=AuthJSONRPCServer.UNAUTHORIZED) err.message, code=JSONRPCError.CODE_AUTHENTICATION_ERROR,
traceback=format_exc()
),
request, id_, version=version
)
return server.NOT_DONE_YET return server.NOT_DONE_YET
self._update_session_secret(session_id) self._update_session_secret(session_id)
reply_with_next_secret = True reply_with_next_secret = True
try: try:
function = self._get_jsonrpc_method(function_name) function = self._get_jsonrpc_method(function_name)
except (UnknownAPIMethodError, NotAllowedDuringStartupError) as err: except UnknownAPIMethodError as err:
log.warning('Failed to get function %s: %s', function_name, err) log.warning('Failed to get function %s: %s', function_name, err)
self._render_error(err, request, version) self._render_error(
JSONRPCError(None, JSONRPCError.CODE_METHOD_NOT_FOUND),
request, version
)
return server.NOT_DONE_YET
except NotAllowedDuringStartupError as err:
log.warning('Function not allowed during startup %s: %s', function_name, err)
self._render_error(
JSONRPCError("This method is unavailable until the daemon is fully started",
code=JSONRPCError.CODE_INVALID_REQUEST),
request, version
)
return server.NOT_DONE_YET return server.NOT_DONE_YET
if args == EMPTY_PARAMS or args == []: if args == EMPTY_PARAMS or args == []:
d = defer.maybeDeferred(function) args_dict = {}
elif isinstance(args, dict): elif isinstance(args, dict):
d = defer.maybeDeferred(function, **args) args_dict = args
elif len(args) == 1 and isinstance(args[0], dict): elif len(args) == 1 and isinstance(args[0], dict):
# TODO: this is for backwards compatibility. Remove this once API and UI are updated # TODO: this is for backwards compatibility. Remove this once API and UI are updated
# TODO: also delete EMPTY_PARAMS then # TODO: also delete EMPTY_PARAMS then
d = defer.maybeDeferred(function, **args[0]) args_dict = args[0]
else: else:
# d = defer.maybeDeferred(function, *args) # if we want to support positional args too # d = defer.maybeDeferred(function, *args) # if we want to support positional args too
raise ValueError('Args must be a dict') raise ValueError('Args must be a dict')
params_error, erroneous_params = self._check_params(function, args_dict)
if params_error is not None:
params_error_message = '{} for {} command: {}'.format(
params_error, function_name, ', '.join(erroneous_params)
)
log.warning(params_error_message)
self._render_error(
JSONRPCError(params_error_message, code=JSONRPCError.CODE_INVALID_PARAMS),
request, version
)
return server.NOT_DONE_YET
d = defer.maybeDeferred(function, **args_dict)
# finished_deferred will callback when the request is finished # finished_deferred will callback when the request is finished
# and errback if something went wrong. If the errback is # and errback if something went wrong. If the errback is
# called, cancel the deferred stack. This is to prevent # called, cancel the deferred stack. This is to prevent
@ -234,6 +333,27 @@ class AuthJSONRPCServer(AuthorizedBase):
(utils.now() - time_in).total_seconds())) (utils.now() - time_in).total_seconds()))
return server.NOT_DONE_YET return server.NOT_DONE_YET
@staticmethod
def _check_params(function, args_dict):
argspec = inspect.getargspec(undecorated(function))
missing_required_params = [
required_param
for required_param in argspec.args[1:-len(argspec.defaults or ())]
if required_param not in args_dict
]
if len(missing_required_params):
return 'Missing required parameters', missing_required_params
extraneous_params = [] if argspec.keywords is not None else [
extra_param
for extra_param in args_dict
if extra_param not in argspec.args[1:]
]
if len(extraneous_params):
return 'Extraneous parameters', extraneous_params
return None, None
def _register_user_session(self, session_id): def _register_user_session(self, session_id):
""" """
Add or update a HMAC secret for a session Add or update a HMAC secret for a session
@ -313,10 +433,12 @@ class AuthJSONRPCServer(AuthorizedBase):
return False return False
def _verify_token(self, session_id, message, token): def _verify_token(self, session_id, message, token):
assert token is not None, InvalidAuthenticationToken if token is None:
raise InvalidAuthenticationToken('Authentication token not found')
to_auth = get_auth_message(message) to_auth = get_auth_message(message)
api_key = self.sessions.get(session_id) api_key = self.sessions.get(session_id)
assert api_key.compare_hmac(to_auth, token), InvalidAuthenticationToken if not api_key.compare_hmac(to_auth, token):
raise InvalidAuthenticationToken('Invalid authentication token')
def _update_session_secret(self, session_id): def _update_session_secret(self, session_id):
self.sessions.update({session_id: APIKey.new(name=session_id)}) self.sessions.update({session_id: APIKey.new(name=session_id)})
@ -340,6 +462,7 @@ class AuthJSONRPCServer(AuthorizedBase):
try: try:
encoded_message = jsonrpc_dumps_pretty( encoded_message = jsonrpc_dumps_pretty(
result_for_return, id=id_, version=version, default=default_decimal) result_for_return, id=id_, version=version, default=default_decimal)
request.setResponseCode(200)
self._set_headers(request, encoded_message, auth_required) self._set_headers(request, encoded_message, auth_required)
self._render_message(request, encoded_message) self._render_message(request, encoded_message)
except Exception as err: except Exception as err:

View file

@ -24,8 +24,9 @@ def generate_key(x=None):
return sha(x) return sha(x)
def jsonrpc_dumps_pretty(obj, **kwargs): def jsonrpc_dumps_pretty(obj, sort_keys=True, **kwargs):
return jsonrpclib.dumps(obj, sort_keys=True, indent=2, separators=(',', ': '), **kwargs) + "\n" return jsonrpclib.dumps(obj, sort_keys=sort_keys, indent=2, separators=(',', ': '), **kwargs) \
+ "\n"
class APIKey(object): class APIKey(object):

68
lbrynet/undecorated.py Normal file
View file

@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
# Copyright 2016-2017 Ionuț Arțăriși <ionut@artarisi.eu>
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# This came from https://github.com/mapleoin/undecorated
from inspect import isfunction, ismethod, isclass
__version__ = '0.3.0'
def undecorated(o):
"""Remove all decorators from a function, method or class"""
# class decorator
if type(o) is type:
return o
try:
# python2
closure = o.func_closure
except AttributeError:
pass
# try:
# # python3
# closure = o.__closure__
# except AttributeError:
# return
if closure:
for cell in closure:
# avoid infinite recursion
if cell.cell_contents is o:
continue
# check if the contents looks like a decorator; in that case
# we need to go one level down into the dream, otherwise it
# might just be a different closed-over variable, which we
# can ignore.
# Note: this favors supporting decorators defined without
# @wraps to the detriment of function/method/class closures
if looks_like_a_decorator(cell.cell_contents):
undecd = undecorated(cell.cell_contents)
if undecd:
return undecd
else:
return o
else:
return o
def looks_like_a_decorator(a):
return (
isfunction(a) or ismethod(a) or isclass(a)
)