Skip to content

Commit 1f5449c

Browse files
committed
Fix #36: handle server and client errors in batches gracefully
* catch errors during batch processing * return a new BatchError that has caught exceptions and batch statistics
1 parent ea35192 commit 1f5449c

File tree

6 files changed

+44
-38
lines changed

6 files changed

+44
-38
lines changed

HISTORY.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,12 @@ Server-compatibility release:
4343
* fix param documentation in functional API
4444
* update wire protocol for remove_from_organization with deletion of account to match server changes
4545

46-
### Version TBD
46+
### Version 2.2
4747

4848
Enhancement release:
4949

50+
* [Issue 36](https://github.com/adobe-apiplatform/umapi-client.py/issues/36)
51+
* catch errors during batch processing
52+
* return a new BatchError that has caught exceptions and batch statistics
5053
* (No Issue)
5154
* allow User Sync config key names in the connection `auth_dict`

tests/test_actions.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222

2323
import mock
2424
import pytest
25-
2625
from conftest import mock_connection_params, MockResponse
27-
from umapi_client import Connection, Action, ServerError
26+
27+
from umapi_client import Connection, Action, BatchError
2828

2929

3030
def test_action_create():
@@ -292,25 +292,19 @@ def test_execute_multiple_single_queued_throttle_actions():
292292

293293
def test_execute_multiple_queued_throttle_actions_error():
294294
with mock.patch("umapi_client.connection.requests.Session.post") as mock_post:
295-
mock_post.return_value = MockResponse(500)
295+
mock_post.side_effect = [MockResponse(500),
296+
MockResponse(200, {"result": "success"}),
297+
MockResponse(200, {"result": "success"})]
296298
conn = Connection(throttle_actions=2, **mock_connection_params)
297299
action0 = Action(top="top0").append(a="a0")
298300
action1 = Action(top="top1").append(a="a1")
299301
action2 = Action(top="top2").append(a="a2")
300302
action3 = Action(top="top3").append(a="a3")
301303
action4 = Action(top="top4").append(a="a4")
302304
action5 = Action(top="top5").append(a="a5")
303-
pytest.raises(ServerError, conn.execute_multiple,
305+
pytest.raises(BatchError, conn.execute_multiple,
304306
[action0, action1, action2, action3, action4, action5], immediate=False)
305307
local_status, _ = conn.status(remote=False)
306-
assert local_status == {"multiple-query-count": 0,
307-
"single-query-count": 0,
308-
"actions-sent": 2,
309-
"actions-completed": 0,
310-
"actions-queued": 4}
311-
mock_post.return_value = MockResponse(200, {"result": "success"})
312-
assert conn.execute_queued() == (0, 4, 4)
313-
local_status, _ = conn.status(remote=False)
314308
assert local_status == {"multiple-query-count": 0,
315309
"single-query-count": 0,
316310
"actions-sent": 6,

umapi_client/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
from .connection import Connection
2222
from .api import Action, QuerySingle, QueryMultiple
23-
from .error import ClientError, RequestError, ServerError, UnavailableError
23+
from .error import BatchError, ClientError, RequestError, ServerError, UnavailableError
2424
from .functional import IdentityTypes, GroupTypes, RoleTypes, IfAlreadyExistsOptions
2525
from .functional import UserAction, UserQuery, UsersQuery
2626
from .functional import UserGroupAction, GroupsQuery

umapi_client/connection.py

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
import six.moves.urllib.parse as urlparse
3030

3131
from .auth import JWT, Auth, AccessRequest
32-
from .error import UnavailableError, ClientError, RequestError, ServerError
32+
from .error import BatchError, UnavailableError, ClientError, RequestError, ServerError
3333
from .version import __version__ as umapi_version
3434

3535

@@ -285,13 +285,18 @@ def execute_multiple(self, actions, immediate=True):
285285
286286
NOTE: This is where we throttle the number of commands per action. So the number
287287
of actions we were given may not be the same as the number we queue or send to the server.
288+
289+
NOTE: If the server gives us a response we don't understand, we note that and continue
290+
processing as usual. Then, at the end of the batch, we throw in order to warn the client
291+
that we had a problem understanding the server.
288292
289293
:param actions: the list of Action objects to be executed
290294
:param immediate: whether to immediately send them to the server
291295
:return: tuple: the number of actions in the queue, that got sent, and that executed successfully.
292296
"""
293297
# throttling part 1: split up each action into smaller actions, as needed
294298
split_actions = []
299+
exceptions = []
295300
for a in actions:
296301
if len(a.commands) == 0:
297302
if self.logger: self.logger.warning("Sending action with no commands: %s", a.frame)
@@ -303,29 +308,24 @@ def execute_multiple(self, actions, immediate=True):
303308
split_actions.append(a)
304309
actions = self.action_queue + split_actions
305310
# throttling part 2: execute the action list in batches, as needed
306-
sent = completed = last_batch_sent = last_batch_completed = 0
307-
try:
308-
while len(actions) >= self.throttle_actions:
309-
batch, actions = actions[0:self.throttle_actions], actions[self.throttle_actions:]
310-
if self.logger: self.logger.debug("Executing %d actions (%d remaining).", len(batch), len(actions))
311-
sent += len(batch)
312-
completed += self._execute_batch(batch)
313-
finally:
314-
self.action_queue = actions
315-
self.local_status["actions-queued"] = len(actions)
316-
self.local_status["actions-sent"] += sent
317-
self.local_status["actions-completed"] += completed
318-
# there may be actions left over
319-
if actions and immediate:
311+
sent = completed = 0
312+
batch_size = self.throttle_actions
313+
min_size = 1 if immediate else batch_size
314+
while len(actions) >= min_size:
315+
batch, actions = actions[0:batch_size], actions[batch_size:]
316+
if self.logger: self.logger.debug("Executing %d actions (%d remaining).", len(batch), len(actions))
317+
sent += len(batch)
320318
try:
321-
last_batch_sent = len(actions)
322-
last_batch_completed += self._execute_batch(actions)
323-
finally:
324-
self.action_queue = []
325-
self.local_status["actions-queued"] = 0
326-
self.local_status["actions-sent"] += last_batch_sent
327-
self.local_status["actions-completed"] += last_batch_completed
328-
return len(self.action_queue), sent + last_batch_sent, completed + last_batch_completed
319+
completed += self._execute_batch(batch)
320+
except Exception as e:
321+
exceptions.append(e)
322+
self.action_queue = actions
323+
self.local_status["actions-queued"] = queued = len(actions)
324+
self.local_status["actions-sent"] += sent
325+
self.local_status["actions-completed"] += completed
326+
if exceptions:
327+
raise BatchError(exceptions, queued, sent, completed)
328+
return queued, sent, completed
329329

330330
def _execute_batch(self, actions):
331331
"""

umapi_client/error.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,12 @@ class ClientError(Exception):
4343
def __init__(self, message, result):
4444
Exception.__init__(self, "Server response not understood: " + message)
4545
self.result = result
46+
47+
48+
class BatchError(Exception):
49+
def __init__(self, causes, queued, sent, completed):
50+
prefix = "Exception{} during batch processing: ".format("s" if len(causes) > 1 else "")
51+
tail = ", ".join([str(cause) for cause in causes])
52+
Exception.__init__(self, prefix + tail)
53+
self.causes = causes
54+
self.statistics = (queued, sent, completed)

umapi_client/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@
1818
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
1919
# SOFTWARE.
2020

21-
__version__ = "2.1"
21+
__version__ = "2.2"

0 commit comments

Comments
 (0)