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.
 
 
 
 

252 lines
7.6 KiB

  1. import asyncio
  2. import html
  3. import inspect
  4. import traceback
  5. import typing
  6. from starlette.concurrency import run_in_threadpool
  7. from starlette.requests import Request
  8. from starlette.responses import HTMLResponse, PlainTextResponse, Response
  9. from starlette.types import ASGIApp, Message, Receive, Scope, Send
  10. STYLES = """
  11. p {
  12. color: #211c1c;
  13. }
  14. .traceback-container {
  15. border: 1px solid #038BB8;
  16. }
  17. .traceback-title {
  18. background-color: #038BB8;
  19. color: lemonchiffon;
  20. padding: 12px;
  21. font-size: 20px;
  22. margin-top: 0px;
  23. }
  24. .frame-line {
  25. padding-left: 10px;
  26. font-family: monospace;
  27. }
  28. .frame-filename {
  29. font-family: monospace;
  30. }
  31. .center-line {
  32. background-color: #038BB8;
  33. color: #f9f6e1;
  34. padding: 5px 0px 5px 5px;
  35. }
  36. .lineno {
  37. margin-right: 5px;
  38. }
  39. .frame-title {
  40. font-weight: unset;
  41. padding: 10px 10px 10px 10px;
  42. background-color: #E4F4FD;
  43. margin-right: 10px;
  44. color: #191f21;
  45. font-size: 17px;
  46. border: 1px solid #c7dce8;
  47. }
  48. .collapse-btn {
  49. float: right;
  50. padding: 0px 5px 1px 5px;
  51. border: solid 1px #96aebb;
  52. cursor: pointer;
  53. }
  54. .collapsed {
  55. display: none;
  56. }
  57. .source-code {
  58. font-family: courier;
  59. font-size: small;
  60. padding-bottom: 10px;
  61. }
  62. """
  63. JS = """
  64. <script type="text/javascript">
  65. function collapse(element){
  66. const frameId = element.getAttribute("data-frame-id");
  67. const frame = document.getElementById(frameId);
  68. if (frame.classList.contains("collapsed")){
  69. element.innerHTML = "&#8210;";
  70. frame.classList.remove("collapsed");
  71. } else {
  72. element.innerHTML = "+";
  73. frame.classList.add("collapsed");
  74. }
  75. }
  76. </script>
  77. """
  78. TEMPLATE = """
  79. <html>
  80. <head>
  81. <style type='text/css'>
  82. {styles}
  83. </style>
  84. <title>Starlette Debugger</title>
  85. </head>
  86. <body>
  87. <h1>500 Server Error</h1>
  88. <h2>{error}</h2>
  89. <div class="traceback-container">
  90. <p class="traceback-title">Traceback</p>
  91. <div>{exc_html}</div>
  92. </div>
  93. {js}
  94. </body>
  95. </html>
  96. """
  97. FRAME_TEMPLATE = """
  98. <div>
  99. <p class="frame-title">File <span class="frame-filename">{frame_filename}</span>,
  100. line <i>{frame_lineno}</i>,
  101. in <b>{frame_name}</b>
  102. <span class="collapse-btn" data-frame-id="{frame_filename}-{frame_lineno}" onclick="collapse(this)">{collapse_button}</span>
  103. </p>
  104. <div id="{frame_filename}-{frame_lineno}" class="source-code {collapsed}">{code_context}</div>
  105. </div>
  106. """ # noqa: E501
  107. LINE = """
  108. <p><span class="frame-line">
  109. <span class="lineno">{lineno}.</span> {line}</span></p>
  110. """
  111. CENTER_LINE = """
  112. <p class="center-line"><span class="frame-line center-line">
  113. <span class="lineno">{lineno}.</span> {line}</span></p>
  114. """
  115. class ServerErrorMiddleware:
  116. """
  117. Handles returning 500 responses when a server error occurs.
  118. If 'debug' is set, then traceback responses will be returned,
  119. otherwise the designated 'handler' will be called.
  120. This middleware class should generally be used to wrap *everything*
  121. else up, so that unhandled exceptions anywhere in the stack
  122. always result in an appropriate 500 response.
  123. """
  124. def __init__(
  125. self, app: ASGIApp, handler: typing.Callable = None, debug: bool = False
  126. ) -> None:
  127. self.app = app
  128. self.handler = handler
  129. self.debug = debug
  130. async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
  131. if scope["type"] != "http":
  132. await self.app(scope, receive, send)
  133. return
  134. response_started = False
  135. async def _send(message: Message) -> None:
  136. nonlocal response_started, send
  137. if message["type"] == "http.response.start":
  138. response_started = True
  139. await send(message)
  140. try:
  141. await self.app(scope, receive, _send)
  142. except Exception as exc:
  143. if not response_started:
  144. request = Request(scope)
  145. if self.debug:
  146. # In debug mode, return traceback responses.
  147. response = self.debug_response(request, exc)
  148. elif self.handler is None:
  149. # Use our default 500 error handler.
  150. response = self.error_response(request, exc)
  151. else:
  152. # Use an installed 500 error handler.
  153. if asyncio.iscoroutinefunction(self.handler):
  154. response = await self.handler(request, exc)
  155. else:
  156. response = await run_in_threadpool(self.handler, request, exc)
  157. await response(scope, receive, send)
  158. # We always continue to raise the exception.
  159. # This allows servers to log the error, or allows test clients
  160. # to optionally raise the error within the test case.
  161. raise exc
  162. def format_line(
  163. self, index: int, line: str, frame_lineno: int, frame_index: int
  164. ) -> str:
  165. values = {
  166. # HTML escape - line could contain < or >
  167. "line": html.escape(line).replace(" ", "&nbsp"),
  168. "lineno": (frame_lineno - frame_index) + index,
  169. }
  170. if index != frame_index:
  171. return LINE.format(**values)
  172. return CENTER_LINE.format(**values)
  173. def generate_frame_html(self, frame: inspect.FrameInfo, is_collapsed: bool) -> str:
  174. code_context = "".join(
  175. self.format_line(index, line, frame.lineno, frame.index) # type: ignore
  176. for index, line in enumerate(frame.code_context or [])
  177. )
  178. values = {
  179. # HTML escape - filename could contain < or >, especially if it's a virtual
  180. # file e.g. <stdin> in the REPL
  181. "frame_filename": html.escape(frame.filename),
  182. "frame_lineno": frame.lineno,
  183. # HTML escape - if you try very hard it's possible to name a function with <
  184. # or >
  185. "frame_name": html.escape(frame.function),
  186. "code_context": code_context,
  187. "collapsed": "collapsed" if is_collapsed else "",
  188. "collapse_button": "+" if is_collapsed else "&#8210;",
  189. }
  190. return FRAME_TEMPLATE.format(**values)
  191. def generate_html(self, exc: Exception, limit: int = 7) -> str:
  192. traceback_obj = traceback.TracebackException.from_exception(
  193. exc, capture_locals=True
  194. )
  195. exc_html = ""
  196. is_collapsed = False
  197. exc_traceback = exc.__traceback__
  198. if exc_traceback is not None:
  199. frames = inspect.getinnerframes(exc_traceback, limit)
  200. for frame in reversed(frames):
  201. exc_html += self.generate_frame_html(frame, is_collapsed)
  202. is_collapsed = True
  203. # escape error class and text
  204. error = (
  205. f"{html.escape(traceback_obj.exc_type.__name__)}: "
  206. f"{html.escape(str(traceback_obj))}"
  207. )
  208. return TEMPLATE.format(styles=STYLES, js=JS, error=error, exc_html=exc_html)
  209. def generate_plain_text(self, exc: Exception) -> str:
  210. return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
  211. def debug_response(self, request: Request, exc: Exception) -> Response:
  212. accept = request.headers.get("accept", "")
  213. if "text/html" in accept:
  214. content = self.generate_html(exc)
  215. return HTMLResponse(content, status_code=500)
  216. content = self.generate_plain_text(exc)
  217. return PlainTextResponse(content, status_code=500)
  218. def error_response(self, request: Request, exc: Exception) -> Response:
  219. return PlainTextResponse("Internal Server Error", status_code=500)