Refactor the code into modules
This commit is contained in:
parent
440e211723
commit
07c5cb2f01
|
@ -12,8 +12,8 @@ ADD requirements.txt .
|
|||
RUN python -m pip install -r requirements.txt
|
||||
|
||||
WORKDIR /app
|
||||
ADD src/alertify.py /app
|
||||
ADD src/gotify.py /app
|
||||
COPY alertify.py /app
|
||||
COPY src /app/src
|
||||
|
||||
# Switching to a non-root user, please refer to https://aka.ms/vscode-docker-python-user-rights
|
||||
RUN useradd appuser && chown -R appuser /app
|
||||
|
|
|
@ -28,6 +28,8 @@ The following environment variables will override any config or default:
|
|||
* Listens on port 8080 by default.
|
||||
* Forwards `resolved` alerts, if not disabled.
|
||||
* Resolved alerts delete the original alert, if enabled.
|
||||
* Requires a Gotify app key to send alerts to Gotify
|
||||
* Requires a Gotify client key to delete original alert on resolution
|
||||
* Defaults, if not sent:
|
||||
| Field | Default value |
|
||||
|-------------|---------------|
|
||||
|
@ -72,6 +74,7 @@ services:
|
|||
- "8080:8080"
|
||||
environment:
|
||||
- TZ=Europe/London
|
||||
- DELETE_ONRESOLVE=true
|
||||
- GOTIFY_KEY=_APPKEY_
|
||||
- GOTIFY_CLIENT=_CLIENTKEY_
|
||||
- GOTIFY_SERVER=gotify
|
||||
|
|
79
alertify.py
Normal file
79
alertify.py
Normal file
|
@ -0,0 +1,79 @@
|
|||
#!/usr/bin/python3
|
||||
"""
|
||||
Main entrypoint to run Alertify
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from src import alertify
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
def parse_cli():
|
||||
"""
|
||||
Function to parse the CLI
|
||||
"""
|
||||
maxlen = max([len(key) for key in alertify.Config.defaults()])
|
||||
defaults = [
|
||||
f' * {key.upper().ljust(maxlen)} (default: {val if val != "" else "None"})'
|
||||
for key, val in alertify.Config.defaults().items()
|
||||
]
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
description='Bridge between Prometheus Alertmanager and Gotify\n',
|
||||
epilog='The following environment variables will override any config or default:\n'
|
||||
+ '\n'.join(defaults),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-c',
|
||||
'--config',
|
||||
default=f'{os.path.splitext(__file__)[0]}.yaml',
|
||||
help=f'path to config YAML. (default: {os.path.splitext(__file__)[0]}.yaml)',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-H',
|
||||
'--healthcheck',
|
||||
action='store_true',
|
||||
help='simply exit with 0 for healthy or 1 when unhealthy',
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
def main():
|
||||
"""
|
||||
main()
|
||||
"""
|
||||
logging.basicConfig(
|
||||
format='%(levelname)s: %(message)s',
|
||||
level=logging.INFO,
|
||||
)
|
||||
|
||||
args = parse_cli()
|
||||
|
||||
# forwarder = alertify.Alertify(args.config)
|
||||
forwarder = alertify.Alertify()
|
||||
|
||||
if forwarder.config.verbose:
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
if args.healthcheck:
|
||||
# Invert the sense of 'healthy' for Unix CLI usage
|
||||
return not forwarder.healthcheck.report()
|
||||
|
||||
if forwarder.config.verbose:
|
||||
logging.debug('Parsed config:')
|
||||
for key, val in forwarder.config.items():
|
||||
logging.debug('%s: %s', key, val)
|
||||
|
||||
forwarder.server.listen_and_run()
|
||||
|
||||
return 0
|
||||
|
||||
sys.exit(main())
|
264
src/alertify.py
264
src/alertify.py
|
@ -1,264 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Module to act as a bridge between Prometheus Alertmanager and Gotify
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from distutils.util import strtobool
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
|
||||
import yaml
|
||||
|
||||
import gotify
|
||||
|
||||
DEFAULTS = {
|
||||
'delete_onresolve': bool(False),
|
||||
'disable_resolved': bool(False),
|
||||
'gotify_client': str(),
|
||||
'gotify_key': str(),
|
||||
'gotify_port': int(80),
|
||||
'gotify_server': str('localhost'),
|
||||
'listen_port': int(8080),
|
||||
'verbose': bool(False),
|
||||
}
|
||||
|
||||
|
||||
class HTTPHandler(SimpleHTTPRequestHandler):
|
||||
"""
|
||||
Class to handle the HTTP requests from a client
|
||||
"""
|
||||
|
||||
config = None
|
||||
|
||||
def _alerts(self):
|
||||
"""
|
||||
Method to handle the request for alerts
|
||||
"""
|
||||
if not healthy(self.config):
|
||||
logging.error('Check requirements')
|
||||
self._respond(500, 'Server not configured correctly')
|
||||
return
|
||||
|
||||
content_length = int(self.headers['Content-Length'])
|
||||
rawdata = self.rfile.read(content_length)
|
||||
|
||||
try:
|
||||
am_msg = json.loads(rawdata.decode())
|
||||
except json.decoder.JSONDecodeError as error:
|
||||
logging.error('Bad JSON: %s', error)
|
||||
self._respond(400, f'Bad JSON: {error}')
|
||||
return
|
||||
|
||||
logging.debug('Received from Alertmanager:\n%s', json.dumps(am_msg, indent=2))
|
||||
|
||||
gotify_client = gotify.Gotify(
|
||||
self.config.get('gotify_server'),
|
||||
self.config.get('gotify_port'),
|
||||
self.config.get('gotify_key'),
|
||||
self.config.get('gotify_client'),
|
||||
)
|
||||
|
||||
for alert in am_msg['alerts']:
|
||||
try:
|
||||
if alert['status'] == 'resolved':
|
||||
if self.config.get('disable_resolved'):
|
||||
logging.info('Ignoring resolved messages')
|
||||
self._respond(
|
||||
200,
|
||||
'Ignored. "resolved" messages are disabled',
|
||||
)
|
||||
continue
|
||||
|
||||
if self.config.get('delete_onresolve'):
|
||||
alert_id = gotify_client.find_byfingerprint(alert)
|
||||
if alert_id:
|
||||
response = gotify_client.delete(alert_id)
|
||||
continue
|
||||
logging.debug('Could not find a matching message to delete.')
|
||||
|
||||
prefix = 'Resolved'
|
||||
else:
|
||||
prefix = alert['labels'].get('severity', 'warning').capitalize()
|
||||
|
||||
gotify_msg = {
|
||||
'title': '{}: {}'.format(
|
||||
prefix,
|
||||
alert['annotations'].get('summary'),
|
||||
),
|
||||
'message': '{}: {}'.format(
|
||||
alert['labels'].get('instance', '[unknown]'),
|
||||
alert['annotations'].get('description', '[nodata]'),
|
||||
),
|
||||
'priority': int(alert['labels'].get('priority', 5)),
|
||||
'extras': {
|
||||
'alertify': {
|
||||
'fingerprint': alert.get('fingerprint', None),
|
||||
}
|
||||
},
|
||||
}
|
||||
except KeyError as error:
|
||||
logging.error('KeyError: %s', error)
|
||||
self._respond(400, f'Missing field: {error}')
|
||||
return
|
||||
|
||||
response = gotify_client.send_alert(gotify_msg)
|
||||
|
||||
try:
|
||||
self._respond(response['status'], response['reason'])
|
||||
except UnboundLocalError:
|
||||
self._respond('204', '')
|
||||
|
||||
def _respond(self, status, message):
|
||||
"""
|
||||
Method to output a simple HTTP status and string to the client
|
||||
"""
|
||||
self.send_response(int(status) or 500)
|
||||
self.end_headers()
|
||||
self.wfile.write(bytes(str(message).encode()))
|
||||
|
||||
# Override built-in method
|
||||
def do_GET(self): # pylint: disable=invalid-name
|
||||
"""
|
||||
Method to handle GET requests
|
||||
"""
|
||||
if self.path == '/healthcheck':
|
||||
if not healthy(self.config):
|
||||
logging.error('Check requirements')
|
||||
self._respond(500, 'ERR')
|
||||
|
||||
self._respond(200, 'OK')
|
||||
|
||||
# Override built-in method
|
||||
def do_POST(self): # pylint: disable=invalid-name
|
||||
"""
|
||||
Method to handle POST requests from AlertManager
|
||||
"""
|
||||
if self.path == '/alert':
|
||||
self._alerts()
|
||||
|
||||
# FIXME: This isn't right. A normal method doesn't work, however.
|
||||
@classmethod
|
||||
def set_config(cls, config):
|
||||
"""
|
||||
Classmethod to add config to the class
|
||||
"""
|
||||
cls.config = config
|
||||
|
||||
|
||||
def healthy(config):
|
||||
"""
|
||||
Simple function to return if all the requirements are met
|
||||
"""
|
||||
return all(
|
||||
[
|
||||
len(config.get('gotify_key', '')),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@functools.lru_cache
|
||||
def parse_config(configfile):
|
||||
"""
|
||||
Function to parse a configuration file
|
||||
"""
|
||||
config = {}
|
||||
|
||||
try:
|
||||
with open(configfile, 'r') as file:
|
||||
parsed = yaml.safe_load(file.read())
|
||||
except FileNotFoundError as error:
|
||||
logging.warning('No config file found (%s)', error.filename)
|
||||
parsed = {}
|
||||
|
||||
# Iterate over the DEFAULTS dictionary and check for environment variables
|
||||
# of the same name, then check for any items in the YAML config, otherwise
|
||||
# use the default values.
|
||||
# Ensure the types are adhered to.
|
||||
for key, val in DEFAULTS.items():
|
||||
config[key] = os.environ.get(key.upper(), parsed.get(key, val))
|
||||
if isinstance(val, bool):
|
||||
config[key] = strtobool(str(config[key]))
|
||||
else:
|
||||
config[key] = type(val)(config[key])
|
||||
|
||||
return config
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
def parse_cli():
|
||||
"""
|
||||
Function to parse the CLI
|
||||
"""
|
||||
maxlen = max([len(key) for key in DEFAULTS])
|
||||
defaults = [
|
||||
f' * {key.upper().ljust(maxlen)} (default: {val if val != "" else "None"})'
|
||||
for key, val in DEFAULTS.items()
|
||||
]
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
description='Bridge between Prometheus Alertmanager and Gotify\n',
|
||||
epilog='The following environment variables will override any config or default:\n'
|
||||
+ '\n'.join(defaults),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-c',
|
||||
'--config',
|
||||
default=f'{os.path.splitext(__file__)[0]}.yaml',
|
||||
help=f'path to config YAML. (default: {os.path.splitext(__file__)[0]}.yaml)',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-H',
|
||||
'--healthcheck',
|
||||
action='store_true',
|
||||
help='simply exit with 0 for healthy or 1 when unhealthy',
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
def main():
|
||||
"""
|
||||
main()
|
||||
"""
|
||||
logging.basicConfig(
|
||||
format='%(levelname)s: %(message)s',
|
||||
level=logging.INFO,
|
||||
)
|
||||
|
||||
args = parse_cli()
|
||||
config = parse_config(args.config)
|
||||
|
||||
if config.get('verbose'):
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
if args.healthcheck:
|
||||
# Invert the sense of 'healthy' for Unix CLI usage
|
||||
return not healthy(config)
|
||||
|
||||
listen_port = config.get('listen_port')
|
||||
|
||||
logging.debug(
|
||||
'Config:\n%s',
|
||||
yaml.dump(config, explicit_start=True, default_flow_style=False),
|
||||
)
|
||||
|
||||
logging.info('Starting web server on port %d', listen_port)
|
||||
try:
|
||||
with HTTPServer(('', listen_port), HTTPHandler) as webserver:
|
||||
HTTPHandler.set_config(config)
|
||||
webserver.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
logging.info('Exiting')
|
||||
|
||||
return 0
|
||||
|
||||
sys.exit(main())
|
41
src/alertify/__init__.py
Normal file
41
src/alertify/__init__.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
"""
|
||||
Alertify module to act as a bridge between Prometheus Alertmanager and Gotify
|
||||
"""
|
||||
|
||||
__author__ = 'Scott Wallace'
|
||||
__email__ = 'scott@wallace.sh'
|
||||
__maintainer__ = 'Scott Wallace'
|
||||
|
||||
__version__ = '1.5'
|
||||
|
||||
from .config import Config
|
||||
from .gotify import Gotify
|
||||
from .server import Server
|
||||
from .healthcheck import Healthcheck
|
||||
from .messaging import MessageHandler
|
||||
|
||||
|
||||
class Alertify:
|
||||
"""
|
||||
Class for Alertify
|
||||
"""
|
||||
|
||||
def __init__(self, configfile=None):
|
||||
self.config = Config(configfile)
|
||||
self.gotify = Gotify(
|
||||
self.config.gotify_server,
|
||||
self.config.gotify_port,
|
||||
self.config.gotify_key,
|
||||
self.config.gotify_client,
|
||||
)
|
||||
self.message_handler = MessageHandler(
|
||||
self.gotify,
|
||||
self.config.disable_resolved,
|
||||
self.config.delete_onresolve,
|
||||
)
|
||||
self.healthcheck = Healthcheck(self.gotify)
|
||||
self.server = Server(
|
||||
self.config.listen_port,
|
||||
self.message_handler,
|
||||
self.healthcheck,
|
||||
)
|
81
src/alertify/config.py
Normal file
81
src/alertify/config.py
Normal file
|
@ -0,0 +1,81 @@
|
|||
"""
|
||||
Module to handle Alertify's configuration
|
||||
"""
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
from distutils.util import strtobool
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class Config:
|
||||
"""
|
||||
Class to handle the config
|
||||
"""
|
||||
|
||||
delete_onresolve = bool(False)
|
||||
disable_resolved = bool(False)
|
||||
gotify_client = str()
|
||||
gotify_key = str()
|
||||
gotify_port = int(80)
|
||||
gotify_server = str('localhost')
|
||||
listen_port = int(8080)
|
||||
verbose = bool(False)
|
||||
|
||||
def __init__(self, configfile=None):
|
||||
"""
|
||||
Method to parse a configuration file
|
||||
"""
|
||||
logging.debug('Parsing config')
|
||||
parsed = {}
|
||||
|
||||
try:
|
||||
with open(configfile, 'r') as file:
|
||||
parsed = yaml.safe_load(file.read())
|
||||
except FileNotFoundError as error:
|
||||
logging.warning('No config file found (%s)', error.filename)
|
||||
except TypeError:
|
||||
logging.warning('No config file provided.')
|
||||
|
||||
# Iterate over the config defaults and check for environment variable
|
||||
# overrides, then check for any items in the config, otherwise
|
||||
# use the default values.
|
||||
for key, default_val in self.defaults().items():
|
||||
userval = os.environ.get(key.upper(), parsed.get(key, default_val))
|
||||
|
||||
# Ensure the types are adhered to.
|
||||
if isinstance(default_val, bool):
|
||||
setattr(self, key, strtobool(str(userval)))
|
||||
else:
|
||||
setattr(self, key, type(default_val)(userval))
|
||||
|
||||
def items(self):
|
||||
"""
|
||||
Method to return an iterator for the configured items
|
||||
"""
|
||||
return {key: getattr(self, key) for key in self.__dict__}.items()
|
||||
|
||||
@classmethod
|
||||
def keys(cls):
|
||||
"""
|
||||
Method to return the defaults as a list of keys
|
||||
"""
|
||||
return [
|
||||
attr[0]
|
||||
for attr in inspect.getmembers(cls)
|
||||
if not attr[0].startswith('_')
|
||||
and not any(
|
||||
[
|
||||
inspect.ismethod(attr[1]),
|
||||
callable(attr[1]),
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def defaults(cls):
|
||||
"""
|
||||
Classmethod to return the defaults as a dictionary
|
||||
"""
|
||||
return {key: getattr(cls, key) for key in cls.keys()}
|
|
@ -96,7 +96,7 @@ class Gotify:
|
|||
Method to return a list of messages from the Gotify server
|
||||
"""
|
||||
if not self.client_key:
|
||||
logging.debug(
|
||||
logging.warning(
|
||||
'No client key is configured. No messages could be retrieved.'
|
||||
)
|
||||
return []
|
||||
|
@ -109,3 +109,9 @@ class Gotify:
|
|||
"""
|
||||
logging.debug('Sending message to Gotify')
|
||||
return self._call('POST', '/message', body=json.dumps(payload, indent=2))
|
||||
|
||||
def healthcheck(self):
|
||||
"""
|
||||
Method to perform a healthcheck against Gotify
|
||||
"""
|
||||
return self._call('GET', '/health')
|
29
src/alertify/healthcheck.py
Normal file
29
src/alertify/healthcheck.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
"""
|
||||
Module for handling any healthcheck related activity
|
||||
"""
|
||||
|
||||
|
||||
class Healthcheck:
|
||||
"""
|
||||
Class to handle the healthchecks
|
||||
"""
|
||||
|
||||
def __init__(self, gotify_client):
|
||||
self.gotify = gotify_client
|
||||
|
||||
def report(self):
|
||||
"""
|
||||
Simple method to return a boolean state of the general health
|
||||
"""
|
||||
|
||||
return all(
|
||||
[
|
||||
self.gotify.healthcheck(),
|
||||
]
|
||||
)
|
||||
|
||||
def gotify_alive(self):
|
||||
"""
|
||||
Simple method to return the Gotify healthcheck response
|
||||
"""
|
||||
return self.gotify.healthcheck()
|
63
src/alertify/messaging.py
Normal file
63
src/alertify/messaging.py
Normal file
|
@ -0,0 +1,63 @@
|
|||
"""
|
||||
Module for handling the messaging
|
||||
"""
|
||||
import logging
|
||||
|
||||
|
||||
class MessageHandler:
|
||||
"""
|
||||
Class to handle alert messaging
|
||||
"""
|
||||
|
||||
def __init__(self, gotify_client, disable_resolved=False, delete_onresolve=False):
|
||||
self.gotify = gotify_client
|
||||
self.disable_resolved = disable_resolved
|
||||
self.delete_onresolve = delete_onresolve
|
||||
|
||||
def process(self, alert):
|
||||
"""
|
||||
Method to process the alert message
|
||||
"""
|
||||
try:
|
||||
if alert['status'] == 'resolved':
|
||||
if self.disable_resolved:
|
||||
logging.info('Ignoring resolved messages')
|
||||
return {
|
||||
'status': 200,
|
||||
'reason': 'Ignored. "resolved" messages are disabled',
|
||||
}
|
||||
|
||||
if self.delete_onresolve:
|
||||
alert_id = self.gotify.find_byfingerprint(alert)
|
||||
if alert_id:
|
||||
return self.gotify.delete(alert_id)
|
||||
logging.warning('Could not find a matching message to delete.')
|
||||
|
||||
prefix = 'resolved'
|
||||
else:
|
||||
prefix = alert['labels'].get('severity', 'warning')
|
||||
|
||||
gotify_msg = {
|
||||
'title': '[{}] {}'.format(
|
||||
prefix.upper(),
|
||||
alert['annotations'].get('summary'),
|
||||
),
|
||||
'message': '{}: {}'.format(
|
||||
alert['labels'].get('instance', '[unknown]'),
|
||||
alert['annotations'].get('description', '[nodata]'),
|
||||
),
|
||||
'priority': int(alert['labels'].get('priority', 5)),
|
||||
'extras': {
|
||||
'alertify': {
|
||||
'fingerprint': alert.get('fingerprint', None),
|
||||
}
|
||||
},
|
||||
}
|
||||
except KeyError as error:
|
||||
logging.error('KeyError: %s', error)
|
||||
return {
|
||||
'status': 400,
|
||||
'reason': f'Missing field: {error}',
|
||||
}
|
||||
|
||||
return self.gotify.send_alert(gotify_msg)
|
80
src/alertify/server.py
Normal file
80
src/alertify/server.py
Normal file
|
@ -0,0 +1,80 @@
|
|||
"""
|
||||
Module to act as a bridge between Prometheus Alertmanager and Gotify
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
|
||||
|
||||
class Server:
|
||||
"""
|
||||
Class to handle the webserver for Alertify
|
||||
"""
|
||||
|
||||
def __init__(self, port, message_handler, healthcheck):
|
||||
self.port = port
|
||||
self.message_handler = message_handler
|
||||
self.healthcheck = healthcheck
|
||||
|
||||
def listen_and_run(self):
|
||||
"""
|
||||
Method to bind to the port and run indefinitely
|
||||
"""
|
||||
logging.info('Starting web server on port %d', self.port)
|
||||
|
||||
# FIXME: Find a better way to handle the injection of these values
|
||||
http_handler = self.HTTPHandler
|
||||
http_handler.message_handler = self.message_handler
|
||||
http_handler.healthcheck = self.healthcheck
|
||||
|
||||
try:
|
||||
with HTTPServer(('', self.port), http_handler) as webserver:
|
||||
webserver.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
logging.info('Exiting')
|
||||
|
||||
class HTTPHandler(SimpleHTTPRequestHandler):
|
||||
"""
|
||||
Class to handle the HTTP requests from a client
|
||||
"""
|
||||
|
||||
def _respond(self, status, message):
|
||||
"""
|
||||
Method to output a simple HTTP status and string to the client
|
||||
"""
|
||||
self.send_response(int(status) or 500)
|
||||
self.end_headers()
|
||||
self.wfile.write(bytes(str(message).encode()))
|
||||
|
||||
def do_GET(self): # pylint: disable=invalid-name
|
||||
"""
|
||||
Method to handle GET requests
|
||||
"""
|
||||
if self.path == '/healthcheck':
|
||||
response = self.healthcheck.gotify_alive()
|
||||
self._respond(response['status'], response['reason'])
|
||||
|
||||
def do_POST(self): # pylint: disable=invalid-name
|
||||
"""
|
||||
Method to handle POST requests from AlertManager
|
||||
"""
|
||||
if self.path == '/alert':
|
||||
try:
|
||||
content_length = int(self.headers['Content-Length'])
|
||||
message = json.loads(self.rfile.read(content_length).decode())
|
||||
except json.decoder.JSONDecodeError as error:
|
||||
logging.error('Bad JSON: %s', error)
|
||||
self._respond(400, f'Bad JSON: {error}')
|
||||
|
||||
logging.debug(
|
||||
'Received from Alertmanager:\n%s',
|
||||
json.dumps(message, indent=2),
|
||||
)
|
||||
|
||||
for alert in message['alerts']:
|
||||
response = self.message_handler.process(alert)
|
||||
try:
|
||||
self._respond(response['status'], response['reason'])
|
||||
except UnboundLocalError:
|
||||
self._respond('204', '')
|
Loading…
Reference in a new issue