workflows: add error handling to webservice call action (#7124)
This commit is contained in:
parent
51bec8d58f
commit
fbef6d3c56
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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')
|
||||
|
|
132
wcs/wf/wscall.py
132
wcs/wf/wscall.py
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in New Issue