_server_sync.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  1. from __future__ import annotations
  2. import base64
  3. import functools
  4. import traceback
  5. from abc import ABC, abstractmethod
  6. from dataclasses import replace
  7. from http import HTTPStatus
  8. from typing import TYPE_CHECKING, TypeVar
  9. from urllib.parse import parse_qs
  10. from . import _server_shared
  11. from ._codec import Codec, get_default_codecs
  12. from ._compression import negotiate_compression, resolve_compressions
  13. from ._envelope import EnvelopeReader, EnvelopeWriter
  14. from ._interceptor_sync import (
  15. BidiStreamInterceptorSync,
  16. ClientStreamInterceptorSync,
  17. InterceptorSync,
  18. MetadataInterceptorInvokerSync,
  19. MetadataInterceptorSync,
  20. ServerStreamInterceptorSync,
  21. UnaryInterceptorSync,
  22. )
  23. from ._protocol import ConnectWireError, HTTPException, ServerProtocol
  24. from ._protocol_connect import (
  25. CONNECT_UNARY_CONTENT_TYPE_PREFIX,
  26. ConnectServerProtocol,
  27. codec_name_from_content_type,
  28. )
  29. from ._protocol_server import negotiate_server_protocol
  30. from ._server_shared import (
  31. EndpointBidiStreamSync,
  32. EndpointClientStreamSync,
  33. EndpointServerStreamSync,
  34. EndpointUnarySync,
  35. )
  36. from .code import Code
  37. from .errors import ConnectError
  38. from .request import Headers, RequestContext
  39. if TYPE_CHECKING:
  40. import sys
  41. from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
  42. from io import BytesIO
  43. if sys.version_info >= (3, 11):
  44. from wsgiref.types import ErrorStream, StartResponse, WSGIEnvironment
  45. else:
  46. from _typeshed.wsgi import ErrorStream, StartResponse, WSGIEnvironment
  47. from .compression import Compression
  48. else:
  49. StartResponse = "wsgiref.types.StartResponse"
  50. WSGIEnvironment = "wsgiref.types.WSGIEnvironment"
  51. _REQ = TypeVar("_REQ")
  52. _RES = TypeVar("_RES")
  53. _BODY_CHUNK_SIZE = 4096
  54. # While _server_shared.EndpointSync is a closed type, we can't indicate that to Python so define
  55. # a more precise type here.
  56. EndpointSync = (
  57. EndpointBidiStreamSync[_REQ, _RES]
  58. | EndpointClientStreamSync[_REQ, _RES]
  59. | EndpointServerStreamSync[_REQ, _RES]
  60. | EndpointUnarySync[_REQ, _RES]
  61. )
  62. def _process_headers(environ: WSGIEnvironment) -> Headers:
  63. headers = Headers()
  64. if "CONTENT_TYPE" in environ:
  65. headers["content-type"] = environ["CONTENT_TYPE"]
  66. if "CONTENT_LENGTH" in environ:
  67. headers["content-length"] = environ["CONTENT_LENGTH"]
  68. for key, value in environ.items():
  69. if key.startswith("HTTP_"):
  70. header = key[5:].replace("_", "-")
  71. headers[header] = value
  72. return headers
  73. def prepare_response_headers(
  74. base_headers: dict[str, list[str]], selected_encoding: str
  75. ) -> dict[str, list[str]]:
  76. """Prepare response headers with the selected compression encoding.
  77. Args:
  78. base_headers: Base response headers.
  79. selected_encoding: Selected compression encoding.
  80. Returns:
  81. The final response headers with content-encoding set.
  82. """
  83. headers = base_headers.copy()
  84. if "content-type" not in headers:
  85. headers["content-type"] = ["application/proto"]
  86. headers["content-encoding"] = [selected_encoding]
  87. headers["vary"] = ["Accept-Encoding"]
  88. return headers
  89. def _read_body_with_content_length(
  90. environ: WSGIEnvironment, content_length: int
  91. ) -> bytes:
  92. input_stream: BytesIO = environ["wsgi.input"]
  93. # Many app servers buffer the entire request before executing the app
  94. # so do an optimistic read before looping.
  95. chunk = input_stream.read(content_length)
  96. if len(chunk) == content_length:
  97. return chunk
  98. bytes_read = len(chunk)
  99. chunks = [chunk]
  100. while bytes_read < content_length:
  101. to_read = content_length - bytes_read
  102. chunk = input_stream.read(to_read)
  103. if not chunk:
  104. break
  105. chunks.append(chunk)
  106. bytes_read += len(chunk)
  107. if bytes_read < content_length:
  108. raise ConnectError(
  109. Code.INVALID_ARGUMENT,
  110. f"request truncated, expected {content_length} bytes but only received {bytes_read} bytes",
  111. )
  112. return b"".join(chunks)
  113. def _read_body(environ: WSGIEnvironment) -> Iterator[bytes]:
  114. input_stream: BytesIO = environ["wsgi.input"]
  115. while True:
  116. chunk = input_stream.read(_BODY_CHUNK_SIZE)
  117. if not chunk:
  118. return
  119. yield chunk
  120. class ConnectWSGIApplication(ABC):
  121. """A WSGI application for the Connect protocol."""
  122. @property
  123. @abstractmethod
  124. def path(self) -> str: ...
  125. def __init__(
  126. self,
  127. *,
  128. endpoints: Mapping[str, EndpointSync],
  129. interceptors: Iterable[InterceptorSync] = (),
  130. read_max_bytes: int | None = None,
  131. compressions: Iterable[Compression] | None = None,
  132. codecs: Iterable[Codec] | None = None,
  133. ) -> None:
  134. """Initialize the WSGI application.
  135. Args:
  136. endpoints: A mapping of URL paths to endpoints. Typically provided directly
  137. by generated code from the Connect Python plugin.
  138. interceptors: A sequence of interceptors to apply to the endpoints.
  139. read_max_bytes: Maximum size of request messages.
  140. compressions: Supported compression algorithms. If unset, defaults to gzip.
  141. If set to empty, disables compression.
  142. codecs: The codecs supported by the server. If unset, defaults to Protocol Buffers
  143. binary and JSON codecs.
  144. """
  145. super().__init__()
  146. if interceptors:
  147. interceptors = [
  148. MetadataInterceptorInvokerSync(interceptor)
  149. if isinstance(interceptor, MetadataInterceptorSync)
  150. else interceptor
  151. for interceptor in interceptors
  152. ]
  153. endpoints = {
  154. path: _apply_interceptors(endpoint, interceptors)
  155. for path, endpoint in endpoints.items()
  156. }
  157. self._endpoints = endpoints
  158. self._read_max_bytes = read_max_bytes
  159. self._compressions = resolve_compressions(compressions)
  160. codecs = codecs if codecs is not None else get_default_codecs()
  161. self._codecs = {codec.name(): codec for codec in codecs}
  162. def __call__(
  163. self, environ: WSGIEnvironment, start_response: StartResponse
  164. ) -> Iterable[bytes]:
  165. ctx: RequestContext | None = None
  166. try:
  167. path = environ["PATH_INFO"]
  168. if not path:
  169. path = "/"
  170. endpoint = self._endpoints.get(path)
  171. if not endpoint and environ["SCRIPT_NAME"] == self.path:
  172. # The application was mounted at the service's path so we reconstruct
  173. # the full URL.
  174. endpoint = self._endpoints.get(self.path + path)
  175. if not endpoint:
  176. raise HTTPException(HTTPStatus.NOT_FOUND, [])
  177. http_method = environ["REQUEST_METHOD"]
  178. http_scheme = environ.get("wsgi.url_scheme", "http")
  179. headers = _process_headers(environ)
  180. if ra := environ.get("REMOTE_ADDR"):
  181. port = environ.get("REMOTE_PORT", "0")
  182. client_address = f"{ra}:{port}"
  183. else:
  184. client_address = None
  185. content_type = headers.get("content-type", "")
  186. protocol = negotiate_server_protocol(content_type)
  187. send_trailers: Callable[[list[tuple[str, str]]], None] | None = None
  188. if protocol.uses_trailers():
  189. send_trailers = environ.get("wsgi.ext.http.send_trailers")
  190. if not send_trailers:
  191. msg = f"WSGI server does not support WSGI trailers extension but protocol for content-type '{content_type}' requires trailers"
  192. raise RuntimeError(msg)
  193. ctx = protocol.create_request_context(
  194. endpoint.method, http_method, http_scheme, headers, client_address
  195. )
  196. if isinstance(endpoint, EndpointUnarySync) and isinstance(
  197. protocol, ConnectServerProtocol
  198. ):
  199. return self._handle_unary(
  200. environ, start_response, http_method, endpoint, ctx, headers
  201. )
  202. return self._handle_stream(
  203. environ, start_response, send_trailers, protocol, headers, endpoint, ctx
  204. )
  205. except Exception as e:
  206. _drain_request_body(environ)
  207. _maybe_log_exception(environ, e)
  208. return self._handle_error(e, ctx, start_response)
  209. def _handle_unary(
  210. self,
  211. environ: WSGIEnvironment,
  212. start_response: StartResponse,
  213. http_method: str,
  214. endpoint: EndpointUnarySync[_REQ, _RES],
  215. ctx: RequestContext[_REQ, _RES],
  216. headers: Headers,
  217. ) -> Iterable[bytes]:
  218. # Handle request based on method
  219. if http_method == "GET":
  220. request, codec = self._handle_get_request(environ, endpoint)
  221. else:
  222. request, codec = self._handle_post_request(environ, endpoint, headers)
  223. # Process request
  224. response = endpoint.function(request, ctx)
  225. # Encode response
  226. res_bytes = codec.encode(response)
  227. base_headers = {
  228. "content-type": [f"{CONNECT_UNARY_CONTENT_TYPE_PREFIX}{codec.name()}"]
  229. }
  230. # Handle compression if accepted
  231. accept_encoding = headers.get("accept-encoding", "identity")
  232. compression = negotiate_compression(accept_encoding, self._compressions)
  233. res_bytes = compression.compress(res_bytes)
  234. response_headers = prepare_response_headers(base_headers, compression.name())
  235. # Convert headers to WSGI format
  236. wsgi_headers: list[tuple[str, str]] = []
  237. for key, values in response_headers.items():
  238. normalized_key = key.lower()
  239. wsgi_headers.extend((normalized_key, value) for value in values)
  240. _add_context_headers(wsgi_headers, ctx)
  241. start_response("200 OK", wsgi_headers)
  242. return [res_bytes]
  243. def _handle_post_request(
  244. self,
  245. environ: WSGIEnvironment,
  246. endpoint: _server_shared.EndpointSync[_REQ, _RES],
  247. request_headers: Headers,
  248. ) -> tuple[_REQ, Codec]:
  249. """Handle POST request with body."""
  250. codec_name = codec_name_from_content_type(
  251. request_headers.get("content-type", ""), stream=False
  252. )
  253. codec = self._codecs.get(codec_name)
  254. if not codec:
  255. raise HTTPException(
  256. HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
  257. [("Accept-Post", "application/json, application/proto")],
  258. )
  259. try:
  260. content_length = environ.get("CONTENT_LENGTH")
  261. content_length = 0 if not content_length else int(content_length)
  262. if content_length > 0:
  263. req_body = _read_body_with_content_length(environ, content_length)
  264. else:
  265. req_body = b"".join(_read_body(environ))
  266. # Handle compression if specified
  267. compression_name = environ.get("HTTP_CONTENT_ENCODING", "identity").lower()
  268. if compression_name != "identity":
  269. compression = self._compressions.get(compression_name)
  270. if not compression:
  271. raise ConnectError(
  272. Code.UNIMPLEMENTED,
  273. f"unknown compression: '{compression_name}': supported encodings are {', '.join(self._compressions.keys())}",
  274. )
  275. try:
  276. req_body = compression.decompress(req_body)
  277. except Exception as e:
  278. raise ConnectError(
  279. Code.INVALID_ARGUMENT,
  280. f"Failed to decompress request body: {e!s}",
  281. ) from e
  282. if (
  283. self._read_max_bytes is not None
  284. and len(req_body) > self._read_max_bytes
  285. ):
  286. raise ConnectError(
  287. Code.RESOURCE_EXHAUSTED,
  288. f"message is larger than configured max {self._read_max_bytes}",
  289. )
  290. try:
  291. return codec.decode(req_body, endpoint.method.input), codec
  292. except Exception as e:
  293. raise ConnectError(
  294. Code.INVALID_ARGUMENT, f"Failed to decode request body: {e!s}"
  295. ) from e
  296. except Exception as e:
  297. if not isinstance(e, ConnectError):
  298. raise ConnectError(
  299. Code.INTERNAL,
  300. str(e), # TODO
  301. ) from e
  302. raise
  303. def _handle_get_request(
  304. self, environ: WSGIEnvironment, endpoint: EndpointUnarySync[_REQ, _RES]
  305. ) -> tuple[_REQ, Codec]:
  306. """Handle GET request with query parameters."""
  307. try:
  308. query_string = environ.get("QUERY_STRING", "")
  309. params = parse_qs(query_string, keep_blank_values=True)
  310. if "message" not in params:
  311. raise ConnectError(
  312. Code.INVALID_ARGUMENT,
  313. "'message' parameter is required for GET requests",
  314. )
  315. message = params["message"][0]
  316. if "base64" in params and params["base64"][0] == "1":
  317. try:
  318. message = base64.urlsafe_b64decode(message + "===")
  319. except Exception as e:
  320. raise ConnectError(
  321. Code.INVALID_ARGUMENT, f"Invalid base64 encoding: {e!s}"
  322. ) from e
  323. else:
  324. message = message.encode("utf-8")
  325. # Handle compression if specified
  326. if "compression" in params:
  327. compression_name = params["compression"][0]
  328. compression = self._compressions.get(compression_name)
  329. if not compression:
  330. raise ConnectError(
  331. Code.UNIMPLEMENTED,
  332. f"unknown compression: '{compression_name}': supported encodings are {', '.join(self._compressions.keys())}",
  333. )
  334. message = compression.decompress(message)
  335. codec_name = params.get("encoding", ("",))[0]
  336. codec = self._codecs.get(codec_name)
  337. if not codec:
  338. raise ConnectError(
  339. Code.UNIMPLEMENTED, f"invalid message encoding: '{codec_name}'"
  340. )
  341. # Handle GET request with proto decoder
  342. try:
  343. # TODO - Use content type from queryparam
  344. request = codec.decode(message, endpoint.method.input)
  345. return request, codec
  346. except Exception as e:
  347. raise ConnectError(
  348. Code.INVALID_ARGUMENT, f"Failed to decode message: {e!s}"
  349. ) from e
  350. except Exception as e:
  351. if not isinstance(e, ConnectError):
  352. raise ConnectError(Code.INTERNAL, str(e)) from e
  353. raise
  354. def _handle_stream(
  355. self,
  356. environ: WSGIEnvironment,
  357. start_response: StartResponse,
  358. send_trailers: Callable[[list[tuple[str, str]]], None] | None,
  359. protocol: ServerProtocol,
  360. headers: Headers,
  361. endpoint: EndpointSync[_REQ, _RES],
  362. ctx: RequestContext[_REQ, _RES],
  363. ) -> Iterable[bytes]:
  364. req_compression, resp_compression = protocol.negotiate_stream_compression(
  365. headers, self._compressions
  366. )
  367. codec_name = protocol.codec_name_from_content_type(
  368. headers.get("content-type", ""), stream=True
  369. )
  370. codec = self._codecs.get(codec_name)
  371. if not codec:
  372. raise HTTPException(
  373. HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
  374. [
  375. (
  376. "Accept-Post",
  377. "application/connect+json, application/connect+proto",
  378. )
  379. ],
  380. )
  381. writer = protocol.create_envelope_writer(codec, resp_compression)
  382. try:
  383. if not req_compression:
  384. raise ConnectError(
  385. Code.UNIMPLEMENTED, "Unrecognized request compression"
  386. )
  387. request_stream = _request_stream(
  388. environ,
  389. endpoint.method.input,
  390. codec,
  391. req_compression,
  392. self._read_max_bytes,
  393. )
  394. match endpoint:
  395. case _server_shared.EndpointUnarySync():
  396. request = _consume_single_request(request_stream)
  397. response = endpoint.function(request, ctx)
  398. response_stream = iter([response])
  399. case _server_shared.EndpointClientStreamSync():
  400. response = endpoint.function(request_stream, ctx)
  401. response_stream = iter([response])
  402. case _server_shared.EndpointServerStreamSync():
  403. request = _consume_single_request(request_stream)
  404. response_stream = endpoint.function(request, ctx)
  405. case _server_shared.EndpointBidiStreamSync():
  406. response_stream = endpoint.function(request_stream, ctx)
  407. # Trigger service logic by consuming the first (possibly only) response message.
  408. first_response = next(response_stream, None)
  409. # Response headers set before the first message should be set to the context and
  410. # we can send them.
  411. _send_stream_response_headers(
  412. start_response, protocol, codec, resp_compression.name(), ctx
  413. )
  414. if first_response is None:
  415. # It's valid for a service method to return no messages, finish the response
  416. # without error.
  417. return [
  418. _end_response(
  419. writer.end(ctx.response_trailers, None), send_trailers
  420. )
  421. ]
  422. # WSGI requires start_response to be called before returning the body iterator.
  423. # This means we cannot call yield in this function since the function would not
  424. # run at all until the iterator is consumed, meaning start_response wouldn't have
  425. # been called in time. So we return the response stream as a separate generator
  426. # function. This means some duplication of error handling.
  427. return _response_stream(
  428. first_response, environ, response_stream, writer, send_trailers, ctx
  429. )
  430. except Exception as e:
  431. # Exception before any response message was returned. An error after the first
  432. # response message will be handled by _response_stream, so here we have a
  433. # full error-only response.
  434. _drain_request_body(environ)
  435. _maybe_log_exception(environ, e)
  436. _send_stream_response_headers(
  437. start_response, protocol, codec, resp_compression.name(), ctx
  438. )
  439. return [
  440. _end_response(
  441. writer.end(
  442. ctx.response_trailers, ConnectWireError.from_exception(e)
  443. ),
  444. send_trailers,
  445. )
  446. ]
  447. def _handle_error(
  448. self, exc: Exception, ctx: RequestContext | None, start_response: StartResponse
  449. ) -> Iterable[bytes]:
  450. """Handle and log errors with detailed information."""
  451. headers: list[tuple[str, str]]
  452. body: list[bytes]
  453. status: str
  454. if isinstance(exc, HTTPException):
  455. headers = exc.headers
  456. body = []
  457. status = f"{exc.status.value} {exc.status.phrase}"
  458. else:
  459. wire_error = ConnectWireError.from_exception(exc)
  460. http_status = wire_error.to_http_status()
  461. headers = [("Content-Type", "application/json")]
  462. body = [wire_error.to_json_bytes()]
  463. status = f"{http_status.code} {http_status.reason}"
  464. if ctx:
  465. _add_context_headers(headers, ctx)
  466. start_response(status, headers)
  467. return body
  468. def _end_response(
  469. message: bytes | Headers,
  470. send_trailers: Callable[[list[tuple[str, str]]], None] | None,
  471. ) -> bytes:
  472. if isinstance(message, bytes):
  473. return message
  474. assert send_trailers is not None # noqa: S101
  475. send_trailers(list(message.allitems()))
  476. return b""
  477. def _add_context_headers(headers: list[tuple[str, str]], ctx: RequestContext) -> None:
  478. headers.extend((key, value) for key, value in ctx.response_headers.allitems())
  479. headers.extend(
  480. (f"trailer-{key}", value) for key, value in ctx.response_trailers.allitems()
  481. )
  482. def _send_stream_response_headers(
  483. start_response: StartResponse,
  484. protocol: ServerProtocol,
  485. codec: Codec,
  486. compression_name: str,
  487. ctx: RequestContext,
  488. ) -> None:
  489. response_headers = [
  490. ("content-type", protocol.content_type(codec)),
  491. (protocol.compression_header_name(), compression_name),
  492. ]
  493. response_headers.extend(
  494. (key, value) for key, value in ctx.response_headers.allitems()
  495. )
  496. start_response("200 OK", response_headers)
  497. def _request_stream(
  498. environ: WSGIEnvironment,
  499. request_class: type[_REQ],
  500. codec: Codec,
  501. compression: Compression,
  502. read_max_bytes: int | None = None,
  503. ) -> Iterator[_REQ]:
  504. reader = EnvelopeReader(request_class, codec, compression, read_max_bytes)
  505. for chunk in _read_body(environ):
  506. yield from reader.feed(chunk)
  507. def _response_stream(
  508. first_response: _RES,
  509. environ: WSGIEnvironment,
  510. response_stream: Iterator[_RES],
  511. writer: EnvelopeWriter,
  512. send_trailers: Callable[[list[tuple[str, str]]], None] | None,
  513. ctx: RequestContext,
  514. ) -> Iterable[bytes]:
  515. error: Exception | None = None
  516. try:
  517. body = writer.write(first_response)
  518. yield body
  519. for message in response_stream:
  520. body = writer.write(message)
  521. yield body
  522. except Exception as e:
  523. error = e
  524. _drain_request_body(environ)
  525. yield _end_response(
  526. writer.end(
  527. ctx.response_trailers,
  528. ConnectWireError.from_exception(error) if error else None,
  529. ),
  530. send_trailers,
  531. )
  532. def _consume_single_request(stream: Iterator[_REQ]) -> _REQ:
  533. req = None
  534. for message in stream:
  535. if req is not None:
  536. raise ConnectError(
  537. Code.UNIMPLEMENTED, "unary request has multiple messages"
  538. )
  539. req = message
  540. if req is None:
  541. raise ConnectError(Code.UNIMPLEMENTED, "unary request has zero messages")
  542. return req
  543. def _apply_interceptors(
  544. endpoint: EndpointSync[_REQ, _RES], interceptors: Sequence[InterceptorSync]
  545. ) -> EndpointSync:
  546. match endpoint:
  547. case EndpointUnarySync():
  548. func = endpoint.function
  549. for interceptor in reversed(interceptors):
  550. if not isinstance(interceptor, UnaryInterceptorSync):
  551. continue
  552. func = functools.partial(interceptor.intercept_unary_sync, func)
  553. return replace(endpoint, function=func)
  554. case EndpointClientStreamSync():
  555. func = endpoint.function
  556. for interceptor in reversed(interceptors):
  557. if not isinstance(interceptor, ClientStreamInterceptorSync):
  558. continue
  559. func = functools.partial(interceptor.intercept_client_stream_sync, func)
  560. return replace(endpoint, function=func)
  561. case EndpointServerStreamSync():
  562. func = endpoint.function
  563. for interceptor in reversed(interceptors):
  564. if not isinstance(interceptor, ServerStreamInterceptorSync):
  565. continue
  566. func = functools.partial(interceptor.intercept_server_stream_sync, func)
  567. return replace(endpoint, function=func)
  568. case EndpointBidiStreamSync():
  569. func = endpoint.function
  570. for interceptor in reversed(interceptors):
  571. if not isinstance(interceptor, BidiStreamInterceptorSync):
  572. continue
  573. func = functools.partial(interceptor.intercept_bidi_stream_sync, func)
  574. return replace(endpoint, function=func)
  575. def _drain_request_body(environ: WSGIEnvironment) -> None:
  576. if environ.get("SERVER_PROTOCOL", "").startswith("HTTP/1"):
  577. # In HTTP/1, the request body should be drained before returning. Generally it's
  578. # best for the application server to handle this, but gunicorn is a famous
  579. # server that doesn't do so, so we go ahead and do it ourselves.
  580. for _ in _read_body(environ):
  581. pass
  582. def _maybe_log_exception(environ: WSGIEnvironment, exc: Exception) -> None:
  583. if isinstance(exc, (ConnectError, HTTPException)):
  584. return
  585. errors: ErrorStream = environ["wsgi.errors"]
  586. errors.write(
  587. f"Exception in WSGI application\n{''.join(traceback.format_exception(type(exc), exc, exc.__traceback__))}"
  588. )