Add cache and offload tasks

- use uwsgi cache2 as workers shared storage
- offload case create/close with uwsgi mules
- refactoring

Change-Id: I25d44e87e552d701f7851f28dba3e7ffc218399a
Related-bug: PROD-30321 (PROD:30321)
diff --git a/sf_notifier/__init__.py b/sf_notifier/__init__.py
index e69de29..4e00609 100644
--- a/sf_notifier/__init__.py
+++ b/sf_notifier/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2018: Mirantis Inc.
+# All Rights Reserved.
+#
+#    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.
diff --git a/sf_notifier/helpers.py b/sf_notifier/helpers.py
index e9c221b..d2c713b 100644
--- a/sf_notifier/helpers.py
+++ b/sf_notifier/helpers.py
@@ -1,18 +1,3 @@
-# Copyright 2018: Mirantis Inc.
-# All Rights Reserved.
-#
-#    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.
-
 import os
 
 
diff --git a/sf_notifier/salesforce/__init__.py b/sf_notifier/salesforce/__init__.py
index e69de29..4e00609 100644
--- a/sf_notifier/salesforce/__init__.py
+++ b/sf_notifier/salesforce/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2018: Mirantis Inc.
+# All Rights Reserved.
+#
+#    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.
diff --git a/sf_notifier/salesforce/cache.py b/sf_notifier/salesforce/cache.py
new file mode 100644
index 0000000..25d76f4
--- /dev/null
+++ b/sf_notifier/salesforce/cache.py
@@ -0,0 +1,63 @@
+import pickle
+
+try:
+    import uwsgi
+except ImportError:
+    def uwsgi_lock(function):
+        return function
+
+    class Uwsgi(dict):
+        def cache_get(self, *args, **kwargs):
+            return self.get(*args, **kwargs)
+
+        def cache_set(self, *args, **kwargs):
+            return self.set(*args, **kwargs)
+
+        def cache_update(self, *args, **kwargs):
+            return self.update(*args, **kwargs)
+
+        def cache_del(self, *args, **kwargs):
+            return self.__delitem__(*args, **kwargs)
+
+        def cache_exists(self, *args, **kwargs):
+            return self.__contains__(*args, **kwargs)
+
+    uwsgi = Uwsgi()
+
+
+class Cache(dict):
+    def get(self, key):
+        return self.__getitem__(key)
+
+    def set(self, key, value):
+        return self.__setitem__(key, value)
+
+    def update(self, key, value):
+        if not self.__contains__(key):
+            return self.set(key, value)
+
+        dump = pickle.dumps(value)
+        return uwsgi.cache_update(key, dump)
+
+    def delete(self, key):
+        if self.__contains__(key):
+            return self.__delitem__(key)
+
+    def __getitem__(self, key):
+        if not self.__contains__(key):
+            return None
+
+        dump = uwsgi.cache_get(key)
+        return pickle.loads(dump)
+
+    def __setitem__(self, key, value):
+        dump = pickle.dumps(value)
+        if self.__contains__(key):
+            uwsgi.cache_update(key, dump)
+        return uwsgi.cache_set(key, dump)
+
+    def __delitem__(self, key):
+        return uwsgi.cache_del(key)
+
+    def __contains__(self, key):
+        return uwsgi.cache_exists(key)
diff --git a/sf_notifier/salesforce/client.py b/sf_notifier/salesforce/client.py
index 71ada18..ee5db91 100644
--- a/sf_notifier/salesforce/client.py
+++ b/sf_notifier/salesforce/client.py
@@ -1,95 +1,26 @@
-# Copyright 2018: Mirantis Inc.
-# All Rights Reserved.
-#
-#    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.
-
-import fcntl
-import hashlib
 import logging
-import os
 import time
 import uuid
-from contextlib import contextmanager
-
-from cachetools import TTLCache
 
 from prometheus_client import Counter, Gauge
 
 from requests import Session
-from requests.exceptions import ConnectionError as RequestsConnectionError
 
 from simple_salesforce import Salesforce
-from simple_salesforce import exceptions as sf_exceptions
+from simple_salesforce.exceptions import (SalesforceAuthenticationFailed,
+                                          SalesforceMalformedRequest,
+                                          SalesforceResourceNotFound)
 
+from cache import Cache
+from decorators import flocked, sf_auth_retry
+from mixins import SalesforceMixin
+from settings import CASE_STATUS, SESSION_FILE, STATE_MAP
 
-STATE_MAP = {
-    'OK': '060 Informational',
-    'UP': '060 Informational',
-    'UNKNOWN': '070 Unknown',
-    'WARNING': '080 Warning',
-    'MINOR': '080 Warning',
-    'MAJOR': '090 Critical',
-    'CRITICAL': '090 Critical',
-    'DOWN': '090 Critical',
-    'UNREACHABLE': '090 Critical',
-}
-
-CONFIG_FIELD_MAP = {
-    'auth_url': 'instance_url',
-    'username': 'username',
-    'password': 'password',
-    'organization_id': 'organizationId',
-    'environment_id': 'environment_id',
-    'sandbox_enabled': 'domain',
-}
-
-ALLOWED_HASHING = ('md5', 'sha256')
-SESSION_FILE = '/tmp/session'
 
 logger = logging.getLogger(__name__)
 
 
-@contextmanager
-def flocked(fd):
-    try:
-        fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
-        yield
-    except IOError:
-        logger.info('Session file locked. Waiting 5 seconds...')
-        time.sleep(5)
-    finally:
-        fcntl.flock(fd, fcntl.LOCK_UN)
-
-
-def sf_auth_retry(method):
-    def wrapper(self, *args, **kwargs):
-        try:
-            return method(self, *args, **kwargs)
-        except sf_exceptions.SalesforceExpiredSession:
-            logger.warning('Salesforce session expired.')
-            self.auth()
-        except RequestsConnectionError:
-            logger.error('Salesforce connection error.')
-            self.auth()
-        return method(self, *args, **kwargs)
-    return wrapper
-
-
-class SfNotifierError(Exception):
-    pass
-
-
-class SalesforceClient(object):
+class SalesforceClient(SalesforceMixin):
 
     def __init__(self, config):
         self.metrics = {
@@ -100,47 +31,16 @@
         self.config = self._validate_config(config)
         self.hash_func = self._hash_func()
         self.environment = self.config.pop('environment_id')
-        self._registered_alerts = TTLCache(maxsize=2048, ttl=300)
+        self._registered_alerts = Cache()
         self.sf = None
         self.session = Session()
         self.auth()
 
-    @staticmethod
-    def _hash_func():
-        name = os.environ.get('SF_NOTIFIER_ALERT_ID_HASH_FUNC', 'sha256')
-        if name in ALLOWED_HASHING:
-            return getattr(hashlib, name)
-        return hashlib.sha256
-
-    @staticmethod
-    def _validate_config(config):
-        kwargs = {}
-
-        for param, field in CONFIG_FIELD_MAP.iteritems():
-            setting_var = param.upper()
-            env_var = 'SFDC_{}'.format(setting_var)
-            kwargs[field] = os.environ.get(
-                env_var, config.get(setting_var))
-
-            if field == 'domain':
-                if kwargs[field] in ['true', 'True', True]:
-                    kwargs[field] = 'test'
-                else:
-                    del kwargs[field]
-                continue
-
-            if kwargs[field] is None:
-                msg = ('Invalid config: missing "{}" field or "{}" environment'
-                       ' variable.').format(param, env_var)
-                logger.error(msg)
-                raise SfNotifierError(msg)
-        return kwargs
-
     def _auth(self, config):
         try:
             config.update({'session': self.session})
             self.sf = Salesforce(**config)
-        except sf_exceptions.SalesforceAuthenticationFailed as ex:
+        except SalesforceAuthenticationFailed as ex:
             logger.error('Salesforce authentication failure: {}.'.format(ex))
             self.metrics['sf_auth_ok'].set(0)
             return False
@@ -149,13 +49,6 @@
         self.metrics['sf_auth_ok'].set(1)
         return True
 
-    def _load_session(self, session_file):
-        lines = session_file.readlines()
-
-        if lines == []:
-            return
-        return lines[0]
-
     def _refresh_ready(self, saved_session):
         if saved_session is None:
             logger.info('Current session is None.')
@@ -213,18 +106,17 @@
         while auth_ok is False:
             auth_ok = self._acquire_session()
 
-    def _get_alert_id(self, labels):
-        alert_id_data = ''
-        for key in sorted(labels):
-            alert_id_data += labels[key].replace(".", "\\.")
-        return self.hash_func(alert_id_data).hexdigest()
-
     @sf_auth_retry
     def _create_case(self, subject, body, labels, alert_id):
 
-        if alert_id in self._registered_alerts:
+        cached_alert = self._registered_alerts.get(alert_id)
+
+        if cached_alert is not None and cached_alert['id'] != 'error':
             logger.warning('Duplicate case for alert: {}.'.format(alert_id))
-            return 1, self._registered_alerts[alert_id]['Id']
+            return cached_alert
+
+        case = self._fmt_alert_update('pending', 1)
+        self._registered_alerts.update(alert_id, case)
 
         severity = labels.get('severity', 'unknown').upper()
         payload = {
@@ -244,22 +136,26 @@
             self.metrics['sf_request_count'].inc()
             case = self.sf.Case.create(payload)
             logger.info('Created case: {}.'.format(case))
-        except sf_exceptions.SalesforceMalformedRequest as ex:
+        except SalesforceMalformedRequest as ex:
             msg = ex.content[0]['message']
             err_code = ex.content[0]['errorCode']
 
             if err_code == 'DUPLICATE_VALUE':
                 logger.warning('Duplicate case: {}.'.format(msg))
                 case_id = msg.split()[-1]
-                self._registered_alerts[alert_id] = {'Id': case_id}
-                return 1, case_id
+                case = self._fmt_alert_update(case_id, 1)
+                self._registered_alerts.update(alert_id, case)
+                return case
 
+            case = self._fmt_alert_update('error', 2, error_msg=msg)
+            self._registered_alerts.update(alert_id, case)
             logger.error('Cannot create case: {}.'.format(msg))
             self.metrics['sf_error_count'].inc()
             raise
 
-        self._registered_alerts[alert_id] = {'Id': case['id']}
-        return 0, case['id']
+        case = self._fmt_alert_update(case['id'], 0)
+        self._registered_alerts.update(alert_id, case)
+        return case
 
     @sf_auth_retry
     def _close_case(self, case_id):
@@ -282,33 +178,36 @@
         logger.info('Try to get case by alert ID: {}.'.format(alert_id))
 
         if alert_id in self._registered_alerts:
-            return self._registered_alerts[alert_id]
+            return self._registered_alerts.get(alert_id)
         try:
             return self.sf.Case.get_by_custom_id('Alert_ID__c', alert_id)
-        except sf_exceptions.SalesforceResourceNotFound:
-            if self._registered_alerts.get(alert_id):
-                del self._registered_alerts[alert_id]
+        except SalesforceResourceNotFound:
+            self._registered_alerts.delete(alert_id)
 
             logger.warning('Alert ID: {} not found.'.format(alert_id))
 
     def create_case(self, subject, body, labels):
-        alert_id = self._get_alert_id(labels)
+        alert_id = self.get_alert_id(labels)
 
-        error_code, case_id = self._create_case(subject, body,
-                                                labels, alert_id)
+        case = self._create_case(subject, body, labels, alert_id)
 
-        self._create_feed_item(subject, body, case_id)
+        if case['error_code'] == 0 or \
+                (self._feed_update_ready(case['last_update']) and
+                 case['id'] not in CASE_STATUS):
+            self._create_feed_item(subject, body, case['id'])
+            case = self._fmt_alert_update(case['id'], case['error_code'])
+            self._registered_alerts.update(alert_id, case)
 
-        response = {'case_id': case_id, 'alert_id': alert_id}
+        response = {'case_id': case['id'], 'alert_id': alert_id}
 
-        if error_code == 1:
+        if case['error_code'] == 1:
             response['status'] = 'duplicate'
         else:
             response['status'] = 'created'
         return response
 
     def close_case(self, labels):
-        alert_id = self._get_alert_id(labels)
+        alert_id = self.get_alert_id(labels)
         case = self._get_case_by_alert_id(alert_id)
 
         response = {'alert_id': alert_id, 'status': 'resolved'}
@@ -316,9 +215,8 @@
         if case is None:
             return response
 
-        if self._registered_alerts.get(alert_id):
-            del self._registered_alerts[alert_id]
+        self._registered_alerts.delete(alert_id)
 
-        response['case_id'] = case['Id']
-        response['closed'] = self._close_case(case['Id'])
+        response['case_id'] = case['id']
+        response['closed'] = self._close_case(case['id'])
         return response
diff --git a/sf_notifier/salesforce/decorators.py b/sf_notifier/salesforce/decorators.py
new file mode 100644
index 0000000..82d845e
--- /dev/null
+++ b/sf_notifier/salesforce/decorators.py
@@ -0,0 +1,36 @@
+from contextlib import contextmanager
+import fcntl
+import logging
+import time
+
+from requests.exceptions import ConnectionError as RequestsConnectionError
+from simple_salesforce.exceptions import SalesforceExpiredSession
+
+
+logger = logging.getLogger(__name__)
+
+
+def sf_auth_retry(method):
+    def wrapper(self, *args, **kwargs):
+        try:
+            return method(self, *args, **kwargs)
+        except SalesforceExpiredSession:
+            logger.warning('Salesforce session expired.')
+            self.auth()
+        except RequestsConnectionError:
+            logger.error('Salesforce connection error.')
+            self.auth()
+        return method(self, *args, **kwargs)
+    return wrapper
+
+
+@contextmanager
+def flocked(fd):
+    try:
+        fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+        yield
+    except IOError:
+        logger.info('Session file locked. Waiting 5 seconds...')
+        time.sleep(5)
+    finally:
+        fcntl.flock(fd, fcntl.LOCK_UN)
diff --git a/sf_notifier/salesforce/exceptions.py b/sf_notifier/salesforce/exceptions.py
new file mode 100644
index 0000000..5ccd54e
--- /dev/null
+++ b/sf_notifier/salesforce/exceptions.py
@@ -0,0 +1,2 @@
+class SfNotifierError(Exception):
+    pass
diff --git a/sf_notifier/salesforce/mixins.py b/sf_notifier/salesforce/mixins.py
new file mode 100644
index 0000000..ce857cd
--- /dev/null
+++ b/sf_notifier/salesforce/mixins.py
@@ -0,0 +1,78 @@
+import datetime
+import hashlib
+import logging
+import os
+
+from exceptions import SfNotifierError
+from settings import (ALLOWED_HASHING, CONFIG_FIELD_MAP,
+                      FEED_UPDATE_INTERVAL)
+
+
+logger = logging.getLogger(__name__)
+
+
+class SalesforceMixin(object):
+
+    @staticmethod
+    def _hash_func():
+        name = os.environ.get('SF_NOTIFIER_ALERT_ID_HASH_FUNC', 'sha256')
+        if name in ALLOWED_HASHING:
+            return getattr(hashlib, name)
+        return hashlib.sha256
+
+    @staticmethod
+    def _feed_update_ready(update_time):
+        now = datetime.datetime.now()
+        feed_update_interval = datetime.timedelta(hours=FEED_UPDATE_INTERVAL)
+        return now - update_time >= feed_update_interval
+
+    @staticmethod
+    def _validate_config(config):
+        kwargs = {}
+
+        for param, field in CONFIG_FIELD_MAP.iteritems():
+            setting_var = param.upper()
+            env_var = 'SFDC_{}'.format(setting_var)
+            kwargs[field] = os.environ.get(
+                env_var, config.get(setting_var))
+
+            if field == 'domain':
+                if kwargs[field] in ['true', 'True', True]:
+                    kwargs[field] = 'test'
+                else:
+                    del kwargs[field]
+                continue
+
+            if kwargs[field] is None:
+                msg = ('Invalid config: missing "{}" field or "{}" environment'
+                       ' variable.').format(param, env_var)
+                logger.error(msg)
+                raise SfNotifierError(msg)
+        return kwargs
+
+    @staticmethod
+    def _load_session(session_file):
+        lines = session_file.readlines()
+
+        if lines == []:
+            return
+        return lines[0]
+
+    @staticmethod
+    def _fmt_alert_update(case_id, error_code, error_msg=None, now=None):
+        now = now or datetime.datetime.now()
+        row = {
+            'id': case_id,
+            'error_code': error_code,
+            'last_update': now
+        }
+        if error_msg is not None:
+            row.update({'error': error_msg})
+        return row
+
+    def get_alert_id(self, labels, hash_func=None):
+        hash_func = hash_func or self.hash_func
+        alert_id_data = ''
+        for key in sorted(labels):
+            alert_id_data += labels[key].replace(".", "\\.")
+        return hash_func(alert_id_data).hexdigest()
diff --git a/sf_notifier/salesforce/settings.py b/sf_notifier/salesforce/settings.py
new file mode 100644
index 0000000..a4f3dd3
--- /dev/null
+++ b/sf_notifier/salesforce/settings.py
@@ -0,0 +1,27 @@
+STATE_MAP = {
+    'OK': '060 Informational',
+    'UP': '060 Informational',
+    'UNKNOWN': '070 Unknown',
+    'WARNING': '080 Warning',
+    'MINOR': '080 Warning',
+    'MAJOR': '090 Critical',
+    'CRITICAL': '090 Critical',
+    'DOWN': '090 Critical',
+    'UNREACHABLE': '090 Critical',
+}
+
+CONFIG_FIELD_MAP = {
+    'auth_url': 'instance_url',
+    'username': 'username',
+    'password': 'password',
+    'organization_id': 'organizationId',
+    'environment_id': 'environment_id',
+    'sandbox_enabled': 'domain',
+}
+
+ALLOWED_HASHING = ('md5', 'sha256')
+SESSION_FILE = '/tmp/session'
+# by default allow to send 2 feed items per hour
+FEED_ITEMS_THROTTLING = 2
+FEED_UPDATE_INTERVAL = 1.0 / FEED_ITEMS_THROTTLING
+CASE_STATUS = ('pending', 'error')
diff --git a/sf_notifier/server.py b/sf_notifier/server.py
index 4d3ddc0..9a5da5d 100644
--- a/sf_notifier/server.py
+++ b/sf_notifier/server.py
@@ -1,35 +1,19 @@
-# Copyright 2018: Mirantis Inc.
-# All Rights Reserved.
-#
-#    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.
-
 import json
 from logging.config import dictConfig
+import time
 
 from flask import Flask, Response, jsonify, request
-
 from prometheus_client import make_wsgi_app
-
 from requests.exceptions import ConnectionError as RequestsConnectionError
 
+from simple_salesforce.exceptions import SalesforceError
+from simple_settings import settings
+from werkzeug.wsgi import DispatcherMiddleware
+from uwsgidecorators import mulefunc
+
 from sf_notifier.helpers import alert_fields_and_action, create_file
 from sf_notifier.salesforce.client import SESSION_FILE, SalesforceClient
-
-from simple_salesforce.exceptions import SalesforceError
-
-from simple_settings import settings
-
-from werkzeug.wsgi import DispatcherMiddleware
+from sf_notifier.salesforce.settings import CASE_STATUS
 
 
 dictConfig(settings.LOGGING)
@@ -52,6 +36,56 @@
     })
 
 
+@mulefunc
+def offload(action, fields):
+    try:
+        getattr(sf_cli, action)(*fields)
+    except (SalesforceError, RequestsConnectionError) as err:
+        msg = 'Salesforce request failure: {}.'.format(err)
+        sf_cli.metrics['sf_error_count'].inc()
+        app.logger.error(msg)
+
+
+def create_case_results(alert_ids):
+    cases = {}
+    is_error = False
+
+    while len(alert_ids) > len(cases):
+        for alert_id in alert_ids:
+            case = sf_cli._registered_alerts.get(alert_id)
+
+            if case is None:
+                continue
+
+            if case['id'] not in CASE_STATUS:
+                cases[alert_id] = {}
+                cases[alert_id]['case'] = case['id']
+            if case['id'] == 'error':
+                if sf_cli._feed_update_ready(case['last_update']):
+                    continue
+                is_error = True
+                cases[alert_id] = {}
+                cases[alert_id]['error'] = case['error']
+
+        time.sleep(0.2)
+    return cases, is_error
+
+
+def close_case_results(alert_ids):
+    # timeout is implicit error
+    cases = {}
+
+    while len(alert_ids) > 0:
+        for alert_id in alert_ids:
+            case = sf_cli._registered_alerts.get(alert_id)
+
+            if case is None:
+                cases[alert_id] = {'status': 'closed'}
+                alert_ids.pop(alert_ids.index(alert_id))
+        time.sleep(0.2)
+    return cases, False
+
+
 @app.route('/hook', methods=['POST'])
 def webhook_receiver():
 
@@ -66,31 +100,21 @@
 
     app.logger.info('Received requests: {}'.format(data))
 
-    cases = []
+    alert_ids = []
     for alert in data['alerts']:
-        try:
-            alert['labels']['env_id'] = sf_cli.environment
-            fields, action = alert_fields_and_action(alert)
-        except KeyError as key:
-            msg = 'Alert misses {} key.'.format(key)
-            app.logger.error(msg)
-            return Response(json.dumps({'error': msg}),
-                            status=400,
-                            mimetype='application/json')
+        alert['labels']['env_id'] = sf_cli.environment
+        alert_ids.append(sf_cli.get_alert_id(alert['labels']))
+        fields, action = alert_fields_and_action(alert)
 
-        if fields:
-            try:
-                cases.append(getattr(sf_cli, action)(*fields))
-            except (SalesforceError, RequestsConnectionError) as err:
-                msg = 'Salesforce request failure: {}.'.format(err)
-                sf_cli.metrics['sf_error_count'].inc()
-                app.logger.error(msg)
-                return Response(json.dumps({'error': msg}),
-                                status=500,
-                                mimetype='application/json')
+        offload(action, fields)
 
-    if len(cases) == 1:
-        return jsonify(cases[0])
+    cases, is_error = globals()[action + '_results'](alert_ids)
+
+    if is_error:
+        return Response(json.dumps(cases),
+                        status=500,
+                        mimetype='application/json')
+
     return jsonify(cases)
 
 
diff --git a/sf_notifier/tests/test_client.py b/sf_notifier/tests/test_client.py
index 059ea24..f505e68 100644
--- a/sf_notifier/tests/test_client.py
+++ b/sf_notifier/tests/test_client.py
@@ -2,7 +2,8 @@
 
 import pytest
 
-from sf_notifier.salesforce.client import SalesforceClient, SfNotifierError
+from sf_notifier.salesforce.client import SalesforceClient
+from sf_notifier.salesforce.exceptions import SfNotifierError
 
 
 ENV_VARS = [
diff --git a/tox.ini b/tox.ini
index 3bbb061..a254a98 100644
--- a/tox.ini
+++ b/tox.ini
@@ -18,16 +18,16 @@
 [testenv:flake8]
 basepython = python2
 commands =
-  flake8 -v
+  flake8 -v --ignore I100,I201,W504
 
 [testenv:pycodestyle]
 basepython = python2
 commands =
-  pycodestyle -v
+  pycodestyle -v --ignore I100,W504
 
 [testenv:bandit]
 basepython = python2
-commands = bandit -r -x tests -s B108,B110,B410 sf_notifier
+commands = bandit -r -x tests -s B108,B110,B101,B301,B403,B410 sf_notifier
 
 [testenv:venv]
 basepython = python2