You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

661 lines
25 KiB

  1. """CherryPy-based server for accessing NILM database via HTTP"""
  2. # Need absolute_import so that "import nilmdb" won't pull in
  3. # nilmdb.py, but will pull the nilmdb module instead.
  4. from __future__ import absolute_import
  5. import nilmdb.server
  6. from nilmdb.utils.printf import *
  7. from nilmdb.server.errors import NilmDBError
  8. from nilmdb.utils.time import string_to_timestamp
  9. import cherrypy
  10. import sys
  11. import os
  12. import socket
  13. import simplejson as json
  14. import decorator
  15. import psutil
  16. import traceback
  17. class NilmApp(object):
  18. def __init__(self, db):
  19. self.db = db
  20. # Decorators
  21. def chunked_response(func):
  22. """Decorator to enable chunked responses."""
  23. # Set this to False to get better tracebacks from some requests
  24. # (/stream/extract, /stream/intervals).
  25. func._cp_config = { 'response.stream': True }
  26. return func
  27. def response_type(content_type):
  28. """Return a decorator-generating function that sets the
  29. response type to the specified string."""
  30. def wrapper(func, *args, **kwargs):
  31. cherrypy.response.headers['Content-Type'] = content_type
  32. return func(*args, **kwargs)
  33. return decorator.decorator(wrapper)
  34. @decorator.decorator
  35. def workaround_cp_bug_1200(func, *args, **kwargs): # pragma: no cover
  36. """Decorator to work around CherryPy bug #1200 in a response
  37. generator.
  38. Even if chunked responses are disabled, LookupError or
  39. UnicodeError exceptions may still be swallowed by CherryPy due to
  40. bug #1200. This throws them as generic Exceptions instead so that
  41. they make it through.
  42. """
  43. exc_info = None
  44. try:
  45. for val in func(*args, **kwargs):
  46. yield val
  47. except (LookupError, UnicodeError):
  48. # Re-raise it, but maintain the original traceback
  49. exc_info = sys.exc_info()
  50. new_exc = Exception(exc_info[0].__name__ + ": " + str(exc_info[1]))
  51. raise new_exc, None, exc_info[2]
  52. finally:
  53. del exc_info
  54. def exception_to_httperror(*expected):
  55. """Return a decorator-generating function that catches expected
  56. errors and throws a HTTPError describing it instead.
  57. @exception_to_httperror(NilmDBError, ValueError)
  58. def foo():
  59. pass
  60. """
  61. def wrapper(func, *args, **kwargs):
  62. exc_info = None
  63. try:
  64. return func(*args, **kwargs)
  65. except expected:
  66. # Re-raise it, but maintain the original traceback
  67. exc_info = sys.exc_info()
  68. new_exc = cherrypy.HTTPError("400 Bad Request", str(exc_info[1]))
  69. raise new_exc, None, exc_info[2]
  70. finally:
  71. del exc_info
  72. # We need to preserve the function's argspecs for CherryPy to
  73. # handle argument errors correctly. Decorator.decorator takes
  74. # care of that.
  75. return decorator.decorator(wrapper)
  76. # Custom CherryPy tools
  77. def CORS_allow(methods):
  78. """This does several things:
  79. Handles CORS preflight requests.
  80. Adds Allow: header to all requests.
  81. Raise 405 if request.method not in method.
  82. It is similar to cherrypy.tools.allow, with the CORS stuff added.
  83. """
  84. request = cherrypy.request.headers
  85. response = cherrypy.response.headers
  86. if not isinstance(methods, (tuple, list)): # pragma: no cover
  87. methods = [ methods ]
  88. methods = [ m.upper() for m in methods if m ]
  89. if not methods: # pragma: no cover
  90. methods = [ 'GET', 'HEAD' ]
  91. elif 'GET' in methods and 'HEAD' not in methods: # pragma: no cover
  92. methods.append('HEAD')
  93. response['Allow'] = ', '.join(methods)
  94. # Allow all origins
  95. if 'Origin' in request:
  96. response['Access-Control-Allow-Origin'] = request['Origin']
  97. # If it's a CORS request, send response.
  98. request_method = request.get("Access-Control-Request-Method", None)
  99. request_headers = request.get("Access-Control-Request-Headers", None)
  100. if (cherrypy.request.method == "OPTIONS" and
  101. request_method and request_headers):
  102. response['Access-Control-Allow-Headers'] = request_headers
  103. response['Access-Control-Allow-Methods'] = ', '.join(methods)
  104. # Try to stop further processing and return a 200 OK
  105. cherrypy.response.status = "200 OK"
  106. cherrypy.response.body = ""
  107. cherrypy.request.handler = lambda: ""
  108. return
  109. # Reject methods that were not explicitly allowed
  110. if cherrypy.request.method not in methods:
  111. raise cherrypy.HTTPError(405)
  112. cherrypy.tools.CORS_allow = cherrypy.Tool('on_start_resource', CORS_allow)
  113. # Helper for json_in tool to process JSON data into normal request
  114. # parameters.
  115. def json_to_request_params(body):
  116. cherrypy.lib.jsontools.json_processor(body)
  117. if not isinstance(cherrypy.request.json, dict):
  118. raise cherrypy.HTTPError(415)
  119. cherrypy.request.params.update(cherrypy.request.json)
  120. # CherryPy apps
  121. class Root(NilmApp):
  122. """Root application for NILM database"""
  123. def __init__(self, db):
  124. super(Root, self).__init__(db)
  125. # /
  126. @cherrypy.expose
  127. def index(self):
  128. raise cherrypy.NotFound()
  129. # /favicon.ico
  130. @cherrypy.expose
  131. def favicon_ico(self):
  132. raise cherrypy.NotFound()
  133. # /version
  134. @cherrypy.expose
  135. @cherrypy.tools.json_out()
  136. def version(self):
  137. return nilmdb.__version__
  138. # /dbinfo
  139. @cherrypy.expose
  140. @cherrypy.tools.json_out()
  141. def dbinfo(self):
  142. """Return a dictionary with the database path,
  143. size of the database in bytes, and free disk space in bytes"""
  144. path = self.db.get_basepath()
  145. return { "path": path,
  146. "size": nilmdb.utils.du(path),
  147. "free": psutil.disk_usage(path).free }
  148. class Stream(NilmApp):
  149. """Stream-specific operations"""
  150. # Helpers
  151. def _get_times(self, start_param, end_param):
  152. (start, end) = (None, None)
  153. if start_param is not None:
  154. start = string_to_timestamp(start_param)
  155. if end_param is not None:
  156. end = string_to_timestamp(end_param)
  157. if start is not None and end is not None:
  158. if start >= end:
  159. raise cherrypy.HTTPError(
  160. "400 Bad Request",
  161. sprintf("start must precede end (%s >= %s)",
  162. start_param, end_param))
  163. return (start, end)
  164. # /stream/list
  165. # /stream/list?layout=float32_8
  166. # /stream/list?path=/newton/prep&extended=1
  167. @cherrypy.expose
  168. @cherrypy.tools.json_out()
  169. def list(self, path = None, layout = None, extended = None):
  170. """List all streams in the database. With optional path or
  171. layout parameter, just list streams that match the given path
  172. or layout.
  173. If extent is not given, returns a list of lists containing
  174. the path and layout: [ path, layout ]
  175. If extended is provided, returns a list of lists containing
  176. extended info: [ path, layout, extent_min, extent_max,
  177. total_rows, total_seconds ]. More data may be added.
  178. """
  179. return self.db.stream_list(path, layout, bool(extended))
  180. # /stream/create?path=/newton/prep&layout=float32_8
  181. @cherrypy.expose
  182. @cherrypy.tools.json_in()
  183. @cherrypy.tools.json_out()
  184. @exception_to_httperror(NilmDBError, ValueError)
  185. @cherrypy.tools.CORS_allow(methods = ["POST"])
  186. def create(self, path, layout):
  187. """Create a new stream in the database. Provide path
  188. and one of the nilmdb.layout.layouts keys.
  189. """
  190. return self.db.stream_create(path, layout)
  191. # /stream/destroy?path=/newton/prep
  192. @cherrypy.expose
  193. @cherrypy.tools.json_in()
  194. @cherrypy.tools.json_out()
  195. @exception_to_httperror(NilmDBError)
  196. @cherrypy.tools.CORS_allow(methods = ["POST"])
  197. def destroy(self, path):
  198. """Delete a stream. Fails if any data is still present."""
  199. return self.db.stream_destroy(path)
  200. # /stream/rename?oldpath=/newton/prep&newpath=/newton/prep/1
  201. @cherrypy.expose
  202. @cherrypy.tools.json_in()
  203. @cherrypy.tools.json_out()
  204. @exception_to_httperror(NilmDBError, ValueError)
  205. @cherrypy.tools.CORS_allow(methods = ["POST"])
  206. def rename(self, oldpath, newpath):
  207. """Rename a stream."""
  208. return self.db.stream_rename(oldpath, newpath)
  209. # /stream/get_metadata?path=/newton/prep
  210. # /stream/get_metadata?path=/newton/prep&key=foo&key=bar
  211. @cherrypy.expose
  212. @cherrypy.tools.json_out()
  213. def get_metadata(self, path, key=None):
  214. """Get metadata for the named stream. If optional
  215. key parameters are specified, only return metadata
  216. matching the given keys."""
  217. try:
  218. data = self.db.stream_get_metadata(path)
  219. except nilmdb.server.nilmdb.StreamError as e:
  220. raise cherrypy.HTTPError("404 Not Found", e.message)
  221. if key is None: # If no keys specified, return them all
  222. key = data.keys()
  223. elif not isinstance(key, list):
  224. key = [ key ]
  225. result = {}
  226. for k in key:
  227. if k in data:
  228. result[k] = data[k]
  229. else: # Return "None" for keys with no matching value
  230. result[k] = None
  231. return result
  232. # Helper for set_metadata and get_metadata
  233. def _metadata_helper(self, function, path, data):
  234. if not isinstance(data, dict):
  235. try:
  236. data = dict(json.loads(data))
  237. except TypeError as e:
  238. raise NilmDBError("can't parse 'data' parameter: " + e.message)
  239. for key in data:
  240. if not (isinstance(data[key], basestring) or
  241. isinstance(data[key], float) or
  242. isinstance(data[key], int)):
  243. raise NilmDBError("metadata values must be a string or number")
  244. function(path, data)
  245. # /stream/set_metadata?path=/newton/prep&data=<json>
  246. @cherrypy.expose
  247. @cherrypy.tools.json_in()
  248. @cherrypy.tools.json_out()
  249. @exception_to_httperror(NilmDBError, LookupError)
  250. @cherrypy.tools.CORS_allow(methods = ["POST"])
  251. def set_metadata(self, path, data):
  252. """Set metadata for the named stream, replacing any existing
  253. metadata. Data can be json-encoded or a plain dictionary."""
  254. self._metadata_helper(self.db.stream_set_metadata, path, data)
  255. # /stream/update_metadata?path=/newton/prep&data=<json>
  256. @cherrypy.expose
  257. @cherrypy.tools.json_in()
  258. @cherrypy.tools.json_out()
  259. @exception_to_httperror(NilmDBError, LookupError, ValueError)
  260. @cherrypy.tools.CORS_allow(methods = ["POST"])
  261. def update_metadata(self, path, data):
  262. """Set metadata for the named stream, replacing any existing
  263. metadata. Data can be json-encoded or a plain dictionary."""
  264. self._metadata_helper(self.db.stream_update_metadata, path, data)
  265. # /stream/insert?path=/newton/prep
  266. @cherrypy.expose
  267. @cherrypy.tools.json_out()
  268. @exception_to_httperror(NilmDBError, ValueError)
  269. @cherrypy.tools.CORS_allow(methods = ["PUT"])
  270. def insert(self, path, start, end):
  271. """
  272. Insert new data into the database. Provide textual data
  273. (matching the path's layout) as a HTTP PUT.
  274. """
  275. # Important that we always read the input before throwing any
  276. # errors, to keep lengths happy for persistent connections.
  277. # Note that CherryPy 3.2.2 has a bug where this fails for GET
  278. # requests, if we ever want to handle those (issue #1134)
  279. body = cherrypy.request.body.read()
  280. # Check path and get layout
  281. if len(self.db.stream_list(path = path)) != 1:
  282. raise cherrypy.HTTPError("404", "No such stream: " + path)
  283. # Check limits
  284. (start, end) = self._get_times(start, end)
  285. # Pass the data directly to nilmdb, which will parse it and
  286. # raise a ValueError if there are any problems.
  287. self.db.stream_insert(path, start, end, body)
  288. # Done
  289. return
  290. # /stream/remove?path=/newton/prep
  291. # /stream/remove?path=/newton/prep&start=1234567890.0&end=1234567899.0
  292. @cherrypy.expose
  293. @cherrypy.tools.json_in()
  294. @cherrypy.tools.json_out()
  295. @exception_to_httperror(NilmDBError)
  296. @cherrypy.tools.CORS_allow(methods = ["POST"])
  297. def remove(self, path, start = None, end = None):
  298. """
  299. Remove data from the backend database. Removes all data in
  300. the interval [start, end). Returns the number of data points
  301. removed.
  302. """
  303. (start, end) = self._get_times(start, end)
  304. total_removed = 0
  305. while True:
  306. (removed, restart) = self.db.stream_remove(path, start, end)
  307. total_removed += removed
  308. if restart is None:
  309. break
  310. start = restart
  311. return total_removed
  312. # /stream/intervals?path=/newton/prep
  313. # /stream/intervals?path=/newton/prep&start=1234567890.0&end=1234567899.0
  314. # /stream/intervals?path=/newton/prep&diffpath=/newton/prep2
  315. @cherrypy.expose
  316. @chunked_response
  317. @response_type("application/x-json-stream")
  318. def intervals(self, path, start = None, end = None, diffpath = None):
  319. """
  320. Get intervals from backend database. Streams the resulting
  321. intervals as JSON strings separated by CR LF pairs. This may
  322. make multiple requests to the nilmdb backend to avoid causing
  323. it to block for too long.
  324. Returns intervals between 'start' and 'end' belonging to
  325. 'path'. If 'diff' is provided, the set-difference between
  326. intervals in 'path' and intervals in 'diffpath' are
  327. returned instead.
  328. Note that the response type is the non-standard
  329. 'application/x-json-stream' for lack of a better option.
  330. """
  331. (start, end) = self._get_times(start, end)
  332. if len(self.db.stream_list(path = path)) != 1:
  333. raise cherrypy.HTTPError("404", "No such stream: " + path)
  334. if diffpath and len(self.db.stream_list(path = diffpath)) != 1:
  335. raise cherrypy.HTTPError("404", "No such stream: " + diffpath)
  336. @workaround_cp_bug_1200
  337. def content(start, end):
  338. # Note: disable chunked responses to see tracebacks from here.
  339. while True:
  340. (ints, restart) = self.db.stream_intervals(path, start, end,
  341. diffpath)
  342. response = ''.join([ json.dumps(i) + "\r\n" for i in ints ])
  343. yield response
  344. if restart is None:
  345. break
  346. start = restart
  347. return content(start, end)
  348. # /stream/extract?path=/newton/prep&start=1234567890.0&end=1234567899.0
  349. @cherrypy.expose
  350. @chunked_response
  351. @response_type("text/plain")
  352. def extract(self, path, start = None, end = None,
  353. count = False, markup = False, binary = False):
  354. """
  355. Extract data from backend database. Streams the resulting
  356. entries as ASCII text lines separated by newlines. This may
  357. make multiple requests to the nilmdb backend to avoid causing
  358. it to block for too long.
  359. If 'count' is True, returns a count rather than actual data.
  360. If 'markup' is True, adds comments to the stream denoting each
  361. interval's start and end timestamp.
  362. If 'binary' is True, return raw binary data, rather than lines
  363. of ASCII-formatted data. Raw binary data is always
  364. little-endian and matches the database types (including an
  365. int64 timestamp).
  366. """
  367. (start, end) = self._get_times(start, end)
  368. # Check path and get layout
  369. if len(self.db.stream_list(path = path)) != 1:
  370. raise cherrypy.HTTPError("404", "No such stream: " + path)
  371. if binary:
  372. cherrypy.response.headers['Content-Type'] = (
  373. "application/octet-stream")
  374. if markup or count:
  375. raise cherrypy.HTTPError("400", "can't mix binary and "
  376. "markup or count modes")
  377. @workaround_cp_bug_1200
  378. def content(start, end):
  379. # Note: disable chunked responses to see tracebacks from here.
  380. if count:
  381. matched = self.db.stream_extract(path, start, end,
  382. count = True)
  383. yield sprintf("%d\n", matched)
  384. return
  385. while True:
  386. (data, restart) = self.db.stream_extract(
  387. path, start, end, count = False,
  388. markup = markup, binary = binary)
  389. yield data
  390. if restart is None:
  391. return
  392. start = restart
  393. return content(start, end)
  394. class Exiter(object):
  395. """App that exits the server, for testing"""
  396. @cherrypy.expose
  397. def index(self):
  398. cherrypy.response.headers['Content-Type'] = 'text/plain'
  399. def content():
  400. yield 'Exiting by request'
  401. raise SystemExit
  402. return content()
  403. index._cp_config = { 'response.stream': True }
  404. class Server(object):
  405. def __init__(self, db, host = '127.0.0.1', port = 8080,
  406. stoppable = False, # whether /exit URL exists
  407. embedded = True, # hide diagnostics and output, etc
  408. fast_shutdown = False, # don't wait for clients to disconn.
  409. force_traceback = False, # include traceback in all errors
  410. basepath = '', # base URL path for cherrypy.tree
  411. ):
  412. # Save server version, just for verification during tests
  413. self.version = nilmdb.__version__
  414. self.embedded = embedded
  415. self.db = db
  416. if not getattr(db, "_thread_safe", None):
  417. raise KeyError("Database object " + str(db) + " doesn't claim "
  418. "to be thread safe. You should pass "
  419. "nilmdb.utils.serializer_proxy(NilmDB)(args) "
  420. "rather than NilmDB(args).")
  421. # Build up global server configuration
  422. cherrypy.config.update({
  423. 'server.socket_host': host,
  424. 'server.socket_port': port,
  425. 'engine.autoreload_on': False,
  426. 'server.max_request_body_size': 8*1024*1024,
  427. })
  428. if self.embedded:
  429. cherrypy.config.update({ 'environment': 'embedded' })
  430. # Build up application specific configuration
  431. app_config = {}
  432. app_config.update({
  433. 'error_page.default': self.json_error_page,
  434. })
  435. # Some default headers to just help identify that things are working
  436. app_config.update({ 'response.headers.X-Jim-Is-Awesome': 'yeah' })
  437. # Set up Cross-Origin Resource Sharing (CORS) handler so we
  438. # can correctly respond to browsers' CORS preflight requests.
  439. # This also limits verbs to GET and HEAD by default.
  440. app_config.update({ 'tools.CORS_allow.on': True,
  441. 'tools.CORS_allow.methods': ['GET', 'HEAD'] })
  442. # Configure the 'json_in' tool to also allow other content-types
  443. # (like x-www-form-urlencoded), and to treat JSON as a dict that
  444. # fills requests.param.
  445. app_config.update({ 'tools.json_in.force': False,
  446. 'tools.json_in.processor': json_to_request_params })
  447. # Send tracebacks in error responses. They're hidden by the
  448. # error_page function for client errors (code 400-499).
  449. app_config.update({ 'request.show_tracebacks' : True })
  450. self.force_traceback = force_traceback
  451. # Patch CherryPy error handler to never pad out error messages.
  452. # This isn't necessary, but then again, neither is padding the
  453. # error messages.
  454. cherrypy._cperror._ie_friendly_error_sizes = {}
  455. # Build up the application and mount it
  456. root = Root(self.db)
  457. root.stream = Stream(self.db)
  458. if stoppable:
  459. root.exit = Exiter()
  460. cherrypy.tree.apps = {}
  461. cherrypy.tree.mount(root, basepath, config = { "/" : app_config })
  462. # Shutdowns normally wait for clients to disconnect. To speed
  463. # up tests, set fast_shutdown = True
  464. if fast_shutdown:
  465. # Setting timeout to 0 triggers os._exit(70) at shutdown, grr...
  466. cherrypy.server.shutdown_timeout = 0.01
  467. else:
  468. cherrypy.server.shutdown_timeout = 5
  469. # Set up the WSGI application pointer for external programs
  470. self.wsgi_application = cherrypy.tree
  471. def json_error_page(self, status, message, traceback, version):
  472. """Return a custom error page in JSON so the client can parse it"""
  473. errordata = { "status" : status,
  474. "message" : message,
  475. "traceback" : traceback }
  476. # Don't send a traceback if the error was 400-499 (client's fault)
  477. try:
  478. code = int(status.split()[0])
  479. if not self.force_traceback:
  480. if code >= 400 and code <= 499:
  481. errordata["traceback"] = ""
  482. except Exception: # pragma: no cover
  483. pass
  484. # Override the response type, which was previously set to text/html
  485. cherrypy.serving.response.headers['Content-Type'] = (
  486. "application/json;charset=utf-8" )
  487. # Undo the HTML escaping that cherrypy's get_error_page function applies
  488. # (cherrypy issue 1135)
  489. for k, v in errordata.iteritems():
  490. v = v.replace("&lt;","<")
  491. v = v.replace("&gt;",">")
  492. v = v.replace("&amp;","&")
  493. errordata[k] = v
  494. return json.dumps(errordata, separators=(',',':'))
  495. def start(self, blocking = False, event = None):
  496. if not self.embedded: # pragma: no cover
  497. # Handle signals nicely
  498. if hasattr(cherrypy.engine, "signal_handler"):
  499. cherrypy.engine.signal_handler.subscribe()
  500. if hasattr(cherrypy.engine, "console_control_handler"):
  501. cherrypy.engine.console_control_handler.subscribe()
  502. # Cherrypy stupidly calls os._exit(70) when it can't bind the
  503. # port. At least try to print a reasonable error and continue
  504. # in this case, rather than just dying silently (as we would
  505. # otherwise do in embedded mode)
  506. real_exit = os._exit
  507. def fake_exit(code): # pragma: no cover
  508. if code == os.EX_SOFTWARE:
  509. fprintf(sys.stderr, "error: CherryPy called os._exit!\n")
  510. else:
  511. real_exit(code)
  512. os._exit = fake_exit
  513. cherrypy.engine.start()
  514. os._exit = real_exit
  515. # Signal that the engine has started successfully
  516. if event is not None:
  517. event.set()
  518. if blocking:
  519. try:
  520. cherrypy.engine.wait(cherrypy.engine.states.EXITING,
  521. interval = 0.1, channel = 'main')
  522. except (KeyboardInterrupt, IOError): # pragma: no cover
  523. cherrypy.engine.log('Keyboard Interrupt: shutting down bus')
  524. cherrypy.engine.exit()
  525. except SystemExit: # pragma: no cover
  526. cherrypy.engine.log('SystemExit raised: shutting down bus')
  527. cherrypy.engine.exit()
  528. raise
  529. def stop(self):
  530. cherrypy.engine.exit()
  531. # Use a single global nilmdb.server.NilmDB and nilmdb.server.Server
  532. # instance since the database can only be opened once. For this to
  533. # work, the web server must use only a single process and single
  534. # Python interpreter. Multiple threads are OK.
  535. _wsgi_server = None
  536. def wsgi_application(dbpath, basepath): # pragma: no cover
  537. """Return a WSGI application object with a database at the
  538. specified path.
  539. 'dbpath' is a filesystem location, e.g. /home/nilm/db
  540. 'basepath' is the URL path of the application base, which
  541. is the same as the first argument to Apache's WSGIScriptAlias
  542. directive.
  543. """
  544. def application(environ, start_response):
  545. global _wsgi_server
  546. if _wsgi_server is None:
  547. # Try to start the server
  548. try:
  549. db = nilmdb.utils.serializer_proxy(nilmdb.server.NilmDB)(dbpath)
  550. _wsgi_server = nilmdb.server.Server(
  551. db, embedded = True,
  552. basepath = basepath.rstrip('/'))
  553. except Exception:
  554. # Build an error message on failure
  555. import pprint
  556. err = sprintf("Initializing database at path '%s' failed:\n\n",
  557. dbpath)
  558. err += traceback.format_exc()
  559. try:
  560. import pwd
  561. import grp
  562. err += sprintf("\nRunning as: uid=%d (%s), gid=%d (%s) "
  563. "on host %s, pid %d\n",
  564. os.getuid(), pwd.getpwuid(os.getuid())[0],
  565. os.getgid(), grp.getgrgid(os.getgid())[0],
  566. socket.gethostname(), os.getpid())
  567. except ImportError:
  568. pass
  569. err += sprintf("\nEnvironment:\n%s\n", pprint.pformat(environ))
  570. if _wsgi_server is None:
  571. # Serve up the error with our own mini WSGI app.
  572. headers = [ ('Content-type', 'text/plain'),
  573. ('Content-length', str(len(err))) ]
  574. start_response("500 Internal Server Error", headers)
  575. return [err]
  576. # Call the normal application
  577. return _wsgi_server.wsgi_application(environ, start_response)
  578. return application