2626
2727- HTTP connections persist for the life of the AuthServiceProxy object
2828 (if server supports HTTP/1.1)
29- - sends protocol 'version' , per JSON-RPC 1.1
29+ - sends "jsonrpc":"2.0" , per JSON-RPC 2.0
3030- sends proper, incrementing 'id'
3131- sends Basic HTTP authentication headers
3232- parses all JSON numbers that look like floats as Decimal
3939import http .client
4040import json
4141import logging
42- import os
42+ import pathlib
4343import socket
4444import time
4545import urllib .parse
@@ -60,9 +60,11 @@ def __init__(self, rpc_error, http_status=None):
6060 self .http_status = http_status
6161
6262
63- def EncodeDecimal (o ):
63+ def serialization_fallback (o ):
6464 if isinstance (o , decimal .Decimal ):
6565 return str (o )
66+ if isinstance (o , pathlib .Path ):
67+ return str (o )
6668 raise TypeError (repr (o ) + " is not JSON serializable" )
6769
6870class AuthServiceProxy ():
@@ -78,7 +80,10 @@ def __init__(self, service_url, service_name=None, timeout=HTTP_TIMEOUT, connect
7880 passwd = None if self .__url .password is None else self .__url .password .encode ('utf8' )
7981 authpair = user + b':' + passwd
8082 self .__auth_header = b'Basic ' + base64 .b64encode (authpair )
81- self .timeout = timeout
83+ # clamp the socket timeout, since larger values can cause an
84+ # "Invalid argument" exception in Python's HTTP(S) client
85+ # library on some operating systems (e.g. OpenBSD, FreeBSD)
86+ self .timeout = min (timeout , 2147483 )
8287 self ._set_conn (connection )
8388
8489 def __getattr__ (self , name ):
@@ -91,73 +96,71 @@ def __getattr__(self, name):
9196
9297 def _request (self , method , path , postdata ):
9398 '''
94- Do a HTTP request, with retry if we get disconnected (e.g. due to a timeout).
95- This is a workaround for https://bugs.python.org/issue3566 which is fixed in Python 3.5.
99+ Do a HTTP request.
96100 '''
97101 headers = {'Host' : self .__url .hostname ,
98102 'User-Agent' : USER_AGENT ,
99103 'Authorization' : self .__auth_header ,
100104 'Content-type' : 'application/json' }
101- if os .name == 'nt' :
102- # Windows somehow does not like to re-use connections
103- # TODO: Find out why the connection would disconnect occasionally and make it reusable on Windows
104- # Avoid "ConnectionAbortedError: [WinError 10053] An established connection was aborted by the software in your host machine"
105- self ._set_conn ()
106- try :
107- self .__conn .request (method , path , postdata , headers )
108- return self ._get_response ()
109- except (BrokenPipeError , ConnectionResetError ):
110- # Python 3.5+ raises BrokenPipeError when the connection was reset
111- # ConnectionResetError happens on FreeBSD
112- self .__conn .close ()
113- self .__conn .request (method , path , postdata , headers )
114- return self ._get_response ()
115- except OSError as e :
116- # Workaround for a bug on macOS. See https://bugs.python.org/issue33450
117- retry = '[Errno 41] Protocol wrong type for socket' in str (e )
118- if retry :
119- self .__conn .close ()
120- self .__conn .request (method , path , postdata , headers )
121- return self ._get_response ()
122- else :
123- raise
105+ self .__conn .request (method , path , postdata , headers )
106+ return self ._get_response ()
107+
108+ def _json_dumps (self , obj ):
109+ return json .dumps (obj , default = serialization_fallback , ensure_ascii = self .ensure_ascii )
124110
125111 def get_request (self , * args , ** argsn ):
126112 AuthServiceProxy .__id_count += 1
127113
128- log .debug ("-{}-> {} {}" .format (
114+ log .debug ("-{}-> {} {} {} " .format (
129115 AuthServiceProxy .__id_count ,
130116 self ._service_name ,
131- json .dumps (args or argsn , default = EncodeDecimal , ensure_ascii = self .ensure_ascii ),
117+ self ._json_dumps (args ),
118+ self ._json_dumps (argsn ),
132119 ))
120+
133121 if args and argsn :
134- raise ValueError ('Cannot handle both named and positional arguments' )
135- return {'version' : '1.1' ,
122+ params = dict (args = args , ** argsn )
123+ else :
124+ params = args or argsn
125+ return {'jsonrpc' : '2.0' ,
136126 'method' : self ._service_name ,
137- 'params' : args or argsn ,
127+ 'params' : params ,
138128 'id' : AuthServiceProxy .__id_count }
139129
140130 def __call__ (self , * args , ** argsn ):
141- postdata = json . dumps (self .get_request (* args , ** argsn ), default = EncodeDecimal , ensure_ascii = self . ensure_ascii )
131+ postdata = self . _json_dumps (self .get_request (* args , ** argsn ))
142132 response , status = self ._request ('POST' , self .__url .path , postdata .encode ('utf-8' ))
143- if response ['error' ] is not None :
144- raise JSONRPCException (response ['error' ], status )
145- elif 'result' not in response :
146- raise JSONRPCException ({
147- 'code' : - 343 , 'message' : 'missing JSON-RPC result' }, status )
148- elif status != HTTPStatus .OK :
149- raise JSONRPCException ({
150- 'code' : - 342 , 'message' : 'non-200 HTTP status code but no JSON-RPC error' }, status )
133+ # For backwards compatibility tests, accept JSON RPC 1.1 responses
134+ if 'jsonrpc' not in response :
135+ if response ['error' ] is not None :
136+ raise JSONRPCException (response ['error' ], status )
137+ elif 'result' not in response :
138+ raise JSONRPCException ({
139+ 'code' : - 343 , 'message' : 'missing JSON-RPC result' }, status )
140+ elif status != HTTPStatus .OK :
141+ raise JSONRPCException ({
142+ 'code' : - 342 , 'message' : 'non-200 HTTP status code but no JSON-RPC error' }, status )
143+ else :
144+ return response ['result' ]
151145 else :
146+ assert response ['jsonrpc' ] == '2.0'
147+ if status != HTTPStatus .OK :
148+ raise JSONRPCException ({
149+ 'code' : - 342 , 'message' : 'non-200 HTTP status code' }, status )
150+ if 'error' in response :
151+ raise JSONRPCException (response ['error' ], status )
152+ elif 'result' not in response :
153+ raise JSONRPCException ({
154+ 'code' : - 343 , 'message' : 'missing JSON-RPC 2.0 result and error' }, status )
152155 return response ['result' ]
153156
154157 def batch (self , rpc_call_list ):
155- postdata = json . dumps (list (rpc_call_list ), default = EncodeDecimal , ensure_ascii = self . ensure_ascii )
158+ postdata = self . _json_dumps (list (rpc_call_list ))
156159 log .debug ("--> " + postdata )
157160 response , status = self ._request ('POST' , self .__url .path , postdata .encode ('utf-8' ))
158161 if status != HTTPStatus .OK :
159162 raise JSONRPCException ({
160- 'code' : - 342 , 'message' : 'non-200 HTTP status code but no JSON-RPC error ' }, status )
163+ 'code' : - 342 , 'message' : 'non-200 HTTP status code' }, status )
161164 return response
162165
163166 def _get_response (self ):
@@ -175,17 +178,31 @@ def _get_response(self):
175178 raise JSONRPCException ({
176179 'code' : - 342 , 'message' : 'missing HTTP response from server' })
177180
181+ # Check for no-content HTTP status code, which can be returned when an
182+ # RPC client requests a JSON-RPC 2.0 "notification" with no response.
183+ # Currently this is only possible if clients call the _request() method
184+ # directly to send a raw request.
185+ if http_response .status == HTTPStatus .NO_CONTENT :
186+ if len (http_response .read ()) != 0 :
187+ raise JSONRPCException ({'code' : - 342 , 'message' : 'Content received with NO CONTENT status code' })
188+ return None , http_response .status
189+
178190 content_type = http_response .getheader ('Content-Type' )
179191 if content_type != 'application/json' :
180192 raise JSONRPCException (
181193 {'code' : - 342 , 'message' : 'non-JSON HTTP response with \' %i %s\' from server' % (http_response .status , http_response .reason )},
182194 http_response .status )
183195
184- responsedata = http_response .read ().decode ('utf8' )
196+ data = http_response .read ()
197+ try :
198+ responsedata = data .decode ('utf8' )
199+ except UnicodeDecodeError as e :
200+ raise JSONRPCException ({
201+ 'code' : - 342 , 'message' : f'Cannot decode response in utf8 format, content: { data } , exception: { e } ' })
185202 response = json .loads (responsedata , parse_float = decimal .Decimal )
186203 elapsed = time .time () - req_start_time
187204 if "error" in response and response ["error" ] is None :
188- log .debug ("<-%s- [%.6f] %s" % (response ["id" ], elapsed , json . dumps (response ["result" ], default = EncodeDecimal , ensure_ascii = self . ensure_ascii )))
205+ log .debug ("<-%s- [%.6f] %s" % (response ["id" ], elapsed , self . _json_dumps (response ["result" ])))
189206 else :
190207 log .debug ("<-- [%.6f] %s" % (elapsed , responsedata ))
191208 return response , http_response .status
0 commit comments