workflows: add error handling to webservice call action (#7124)

This commit is contained in:
Frédéric Péters 2015-09-18 13:03:02 +02:00
parent 51bec8d58f
commit fbef6d3c56
6 changed files with 271 additions and 24 deletions

View File

@ -12,7 +12,9 @@ from wcs.qommon import errors, sessions
from qommon.ident.password_accounts import PasswordAccount
from wcs.qommon.http_request import HTTPRequest
from wcs.roles import Role
from wcs.workflows import Workflow, CommentableWorkflowStatusItem
from wcs.workflows import (Workflow, CommentableWorkflowStatusItem,
ChoiceWorkflowStatusItem)
from wcs.wf.wscall import WebserviceCallStatusItem
from wcs.formdef import FormDef
from wcs import fields
@ -629,3 +631,52 @@ def test_backoffice_submission_tracking_code(pub):
resp = resp.follow()
assert 'Check values then click submit.' in resp.body
assert 'test submission' in resp.body
def test_backoffice_wscall_failure_display(pub):
create_user(pub)
create_environment(pub)
formdef = FormDef.get_by_urlname('form-title')
form_class = formdef.data_class()
number31 = [x for x in form_class.select() if x.data['1'] == 'FOO BAR 30'][0].id
number31_status = [x for x in form_class.select() if x.data['1'] == 'FOO BAR 30'][0].status
# attach a custom workflow
workflow = Workflow(name='wscall')
st1 = workflow.add_status('Status1', number31_status.split('-')[1])
wscall = WebserviceCallStatusItem()
wscall.id = '_wscall'
wscall.varname = 'xxx'
wscall.url = 'http://remote.example.net/xml'
wscall.action_on_bad_data = ':stop'
wscall.record_errors = True
st1.items.append(wscall)
wscall.parent = st1
again = ChoiceWorkflowStatusItem()
again.id = '_again'
again.label = 'Again'
again.by = ['_receiver']
again.status = st1.id
st1.items.append(again)
again.parent = st1
workflow.store()
formdef.workflow_id = workflow.id
formdef.store()
app = login(get_app(pub))
resp = app.get('/backoffice/management/form-title/%s/' % number31)
assert (' with the number %s.' % number31) in resp.body
assert 'Again' in resp.body
resp = resp.forms[0].submit('button_again')
resp = resp.follow()
assert 'Error during webservice call' in resp.body
# the failure message shouldn't be displayed in the frontoffice
resp = app.get('/form-title/%s/' % number31)
assert (' with the number %s.' % number31) in resp.body
assert not 'Error during webservice call' in resp.body

View File

@ -13,7 +13,8 @@ from wcs.fields import StringField, DateField
from wcs.roles import Role
from wcs.workflows import (Workflow, WorkflowStatusItem,
SendmailWorkflowStatusItem, SendSMSWorkflowStatusItem,
DisplayMessageWorkflowStatusItem)
DisplayMessageWorkflowStatusItem,
AbortActionException)
from wcs.wf.anonymise import AnonymiseWorkflowStatusItem
from wcs.wf.dispatch import DispatchWorkflowStatusItem
from wcs.wf.form import FormWorkflowStatusItem, WorkflowFormFieldsFormDef
@ -386,6 +387,7 @@ def test_email(pub):
def test_webservice_call(pub):
pub.substitutions.feed(MockSubstitutionVariables())
FormDef.wipe()
formdef = FormDef()
formdef.name = 'baz'
formdef.fields = []
@ -401,7 +403,7 @@ def test_webservice_call(pub):
assert http_requests.get_last('url') == 'http://remote.example.net'
assert http_requests.get_last('method') == 'POST'
payload = json.loads(http_requests.get_last('body'))
assert payload['url'].startswith('http://example.net/baz-')
assert payload['url'] == 'http://example.net/baz/%s/' % formdata.id
assert int(payload['display_id']) == 1
item = WebserviceCallStatusItem()
@ -433,9 +435,17 @@ def test_webservice_call(pub):
assert http_requests.get_last('method') == 'POST'
payload = json.loads(http_requests.get_last('body'))
assert payload['extra'] == {'one': 1, 'str': 'abcd', 'evalme': formdata.id}
assert payload['url'].startswith('http://example.net/baz-')
assert payload['url'] == 'http://example.net/baz/%s/' % formdata.id
assert int(payload['display_id']) == 1
item = WebserviceCallStatusItem()
item.url = 'http://remote.example.net/json'
item.post = False
item.varname = 'xxx'
item.perform(formdata)
assert formdata.workflow_data.get('xxx_status') == 200
assert formdata.workflow_data.get('xxx_response') == {'foo': 'bar'}
item = WebserviceCallStatusItem()
item.url = 'http://remote.example.net'
item.post = False
@ -458,6 +468,71 @@ def test_webservice_call(pub):
item.perform(formdata)
assert 'signature=' in http_requests.get_last('url')
item = WebserviceCallStatusItem()
item.url = 'http://remote.example.net/404'
item.post = False
with pytest.raises(AbortActionException):
item.perform(formdata)
item = WebserviceCallStatusItem()
item.url = 'http://remote.example.net/404'
item.action_on_4xx = ':pass'
item.post = False
item.perform(formdata)
item = WebserviceCallStatusItem()
item.url = 'http://remote.example.net/500'
item.post = False
with pytest.raises(AbortActionException):
item.perform(formdata)
item = WebserviceCallStatusItem()
item.url = 'http://remote.example.net/500'
item.action_on_5xx = ':pass'
item.post = False
item.perform(formdata)
item = WebserviceCallStatusItem()
item.url = 'http://remote.example.net/500'
item.action_on_5xx = '4' # jump to status
item.post = False
with pytest.raises(AbortActionException):
item.perform(formdata)
assert formdata.status == 'wf-4'
item = WebserviceCallStatusItem()
item.url = 'http://remote.example.net/xml'
item.post = False
item.perform(formdata)
item = WebserviceCallStatusItem()
item.url = 'http://remote.example.net/xml'
item.post = False
item.varname = 'xxx'
item.action_on_bad_data = ':stop'
with pytest.raises(AbortActionException):
item.perform(formdata)
item = WebserviceCallStatusItem()
item.url = 'http://remote.example.net/404'
item.post = False
item.record_errors = True
item.action_on_4xx = ':stop'
with pytest.raises(AbortActionException):
item.perform(formdata)
assert formdata.evolution[-1].parts[-1].summary == '404 whatever'
item = WebserviceCallStatusItem()
item.url = 'http://remote.example.net/xml'
item.post = False
item.varname = 'xxx'
item.action_on_bad_data = ':stop'
item.record_errors = True
with pytest.raises(AbortActionException):
item.perform(formdata)
assert formdata.evolution[-1].parts[-1].summary == 'ValueError: No JSON object could be decoded\n'
def test_timeout(pub):
workflow = Workflow(name='timeout')
st1 = workflow.add_status('Status1', 'st1')

View File

@ -215,10 +215,20 @@ class HttpRequestsMocking(object):
'headers': headers,
'timeout': timeout})
response = ''
status = 200
data = ''
return None, status, data, None
status, data = {
'http://remote.example.net/404': (404, 'page not found'),
'http://remote.example.net/500': (500, 'internal server error'),
'http://remote.example.net/json': (200, '{"foo": "bar"}'),
'http://remote.example.net/xml': (200, '<?xml version="1.0"><foo/>'),
}.get(url, (200, ''))
class FakeResponse(object):
def __init__(self, status, data):
self.status = status
self.reason = 'whatever'
self.data = data
return FakeResponse(status, data), status, data, None
def get_last(self, attribute):
return self.requests[-1][attribute]

View File

@ -254,6 +254,7 @@ def _http_request(url, method='GET', body=None, headers={}, timeout=None):
try:
conn.request(method, query, body, headers)
response = conn.getresponse()
except socket.gaierror, (err, msg):
conn.close()
raise ConnectionError('error while connecting to %s : %s' % (hostname, msg))
@ -264,7 +265,6 @@ def _http_request(url, method='GET', body=None, headers={}, timeout=None):
conn.close()
raise ConnectionError('error while fetching the page : %s' % err)
else:
response = conn.getresponse()
data = response.read()
status = response.status
auth_header = response.getheader('WWW-Authenticate')

View File

@ -16,15 +16,37 @@
import json
import sys
import traceback
import xml.etree.ElementTree as ET
from quixote.html import TemplateIO, htmltext
from qommon.errors import ConnectionError
from qommon.form import *
from qommon.misc import http_get_page, http_post_request, get_variadic_url, JSONEncoder
from wcs.workflows import WorkflowStatusItem, register_item_class, template_on_formdata
from wcs.workflows import (WorkflowStatusItem, register_item_class,
template_on_formdata, AbortActionException)
from wcs.api import sign_url
TIMEOUT = 15
class JournalWsCallErrorPart: #pylint: disable=C1001
content = None
def __init__(self, summary, data):
self.summary = summary
self.data = data[:10000] # beware of huge responses
def view(self):
if not get_request().get_path().startswith('/backoffice/'):
return ''
r = TemplateIO(html=True)
r += htmltext('<div class="ws-error">')
r += htmltext('<h4>%s</h4>') % _('Error during webservice call')
r += htmltext('<p>%s</p>') % self.summary
r += htmltext('</div>')
return r.getvalue()
class WebserviceCallStatusItem(WorkflowStatusItem):
description = N_('Webservice Call')
key = 'webservice_call'
@ -36,8 +58,18 @@ class WebserviceCallStatusItem(WorkflowStatusItem):
request_signature_key = None
post_data = None
action_on_4xx = ':stop'
action_on_5xx = ':stop'
action_on_bad_data = ':pass'
action_on_network_errors = ':stop'
notify_on_errors = True
record_errors = False
def get_parameters(self):
return ('url', 'post', 'varname', 'request_signature_key', 'post_data')
return ('url', 'post', 'varname', 'request_signature_key', 'post_data',
'action_on_4xx', 'action_on_5xx', 'action_on_bad_data',
'action_on_network_errors', 'notify_on_errors',
'record_errors')
def add_parameters_widgets(self, form, parameters, prefix='', formdef=None):
if 'url' in parameters:
@ -60,6 +92,34 @@ class WebserviceCallStatusItem(WorkflowStatusItem):
title=_('Request Signature Key'),
value=self.request_signature_key)
error_actions = [(':stop', _('Stop')), (':pass', _('Ignore'))]
error_actions.extend([(x.id, _('Jump to %s') % x.name) for x in
self.parent.parent.possible_status])
for attribute in ('action_on_4xx', 'action_on_5xx', 'action_on_bad_data',
'action_on_network_errors'):
if not attribute in parameters:
continue
label = {
'action_on_4xx': _('Action on HTTP error 4xx'),
'action_on_5xx': _('Action on HTTP error 5xx'),
'action_on_bad_data': _('Action on non-JSON response'),
'action_on_network_errors': _('Action on network errors')
}.get(attribute)
form.add(SingleSelectWidget, '%s%s' % (prefix, attribute),
title=label,
value=getattr(self, attribute),
options=error_actions)
if 'notify_on_errors' in parameters:
form.add(CheckboxWidget, '%snotify_on_errors' % prefix,
title=_('Notify on errors'),
value=self.notify_on_errors)
if 'record_errors' in parameters:
form.add(CheckboxWidget, '%srecord_errors' % prefix,
title=_('Record errors in the journal'),
value=self.record_errors)
def perform(self, formdata):
if not self.url:
# misconfigured action
@ -95,22 +155,28 @@ class WebserviceCallStatusItem(WorkflowStatusItem):
formdata_dict['extra'] = post_data
post_data = formdata_dict
if post_data is not None:
post_data = json.dumps(post_data, cls=JSONEncoder,
encoding=get_publisher().site_charset)
response, status, data, authheader = http_post_request(
url, post_data, headers=headers, timeout=TIMEOUT)
else:
response, status, data, auth_header = http_get_page(
url, headers=headers, timeout=TIMEOUT)
try:
if post_data is not None:
post_data = json.dumps(post_data, cls=JSONEncoder,
encoding=get_publisher().site_charset)
response, status, data, authheader = http_post_request(
url, post_data, headers=headers, timeout=TIMEOUT)
else:
response, status, data, auth_header = http_get_page(
url, headers=headers, timeout=TIMEOUT)
except ConnectionError as e:
status = 0
self.action_on_error(self.action_on_network_errors, formdata,
exc_info=sys.exc_info())
if self.varname:
workflow_data = {'%s_status' % self.varname: status}
if status == 200:
try:
d = json.loads(data)
except (ValueError, TypeError):
pass
except (ValueError, TypeError) as e:
self.action_on_error(self.action_on_bad_data, formdata,
response, data=data, exc_info=sys.exc_info())
else:
workflow_data['%s_response' % self.varname] = d
if isinstance(d.get('data'), dict) and d['data'].get('display_id'):
@ -120,8 +186,46 @@ class WebserviceCallStatusItem(WorkflowStatusItem):
formdata.update_workflow_data(workflow_data)
formdata.store()
if (status // 100) not in (2, 3):
raise Exception("Call to webservice gave %s as status code" % status)
if (status // 100) == 4:
self.action_on_error(self.action_on_4xx, formdata, response, data=data)
if (status // 100) == 5:
self.action_on_error(self.action_on_5xx, formdata, response, data=data)
def action_on_error(self, action, formdata, response=None, data=None, exc_info=None):
if action in (':pass', ':stop') and (self.notify_on_errors or self.record_errors):
if exc_info:
summary = traceback.format_exception_only(exc_info[0], exc_info[1])[-1]
else:
summary = ''
if response:
summary = '%s %s' % (response.status, response.reason)
try:
raise Exception(summary)
except Exception as e:
exc_info = sys.exc_info()
if self.notify_on_errors:
get_publisher().notify_of_exception(exc_info, context='[WSCALL]')
if self.record_errors and formdata.evolution:
formdata.evolution[-1].add_part(JournalWsCallErrorPart(summary, data))
formdata.store()
if action == ':pass':
return
if action == ':stop':
raise AbortActionException()
formdata.status = 'wf-%s' % action
formdata.store()
raise AbortActionException()
def get_target_status(self):
targets = []
for attribute in ('action_on_4xx', 'action_on_5xx', 'action_on_bad_data',
'action_on_network_errors'):
value = getattr(self, attribute)
if value in (':pass', ':stop'):
continue
targets.append(self.parent.parent.get_status(value))
return targets
def post_data_export_to_xml(self, xml_item, charset, include_id=False):
if not self.post_data:

View File

@ -52,6 +52,10 @@ class WorkflowImportError(Exception):
pass
class AbortActionException(Exception):
pass
class AttachmentEvolutionPart: #pylint: disable=C1001
orig_filename = None
base_filename = None
@ -594,7 +598,10 @@ class WorkflowStatus(object):
url = None
old_status = formdata.status
for item in self.items:
url = item.perform(formdata) or url
try:
url = item.perform(formdata) or url
except AbortActionException:
break
if formdata.status != old_status:
break
if formdata.status != old_status: