errors.py 7.8 KB

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