ssl/asyncio:即使处理了错误也能回溯

ssl/asyncio: traceback even when error is handled

正在尝试从 URL 下载和处理 jpeg。我的问题不是某些 URL 的证书验证失败,因为这些 URL 很旧并且可能不再值得信赖,而是当我 try...except... SSLCertVerificationError 时,我仍然得到回溯。

系统: Linux 4.17.14-arch1-1-ARCH,python 3.7.0-3,aiohttp 3.3.2

最小示例:

import asyncio
import aiohttp
from ssl import SSLCertVerificationError

async def fetch_url(url, client):
    try:
        async with client.get(url) as resp:
            print(resp.status)
            print(await resp.read())
    except SSLCertVerificationError as e:
        print('Error handled')

async def main(urls):
    tasks = []
    async with aiohttp.ClientSession(loop=loop) as client:
        for url in urls:
            task = asyncio.ensure_future(fetch_url(url, client))
            tasks.append(task)
        return await asyncio.gather(*tasks)

loop = asyncio.get_event_loop()
loop.run_until_complete(main(['https://images.photos.com/']))

输出:

SSL handshake failed on verifying the certificate
protocol: <asyncio.sslproto.SSLProtocol object at 0x7ffbecad8ac8>
transport: <_SelectorSocketTransport fd=6 read=polling write=<idle, bufsize=0>>
Traceback (most recent call last):
  File "/usr/lib/python3.7/asyncio/sslproto.py", line 625, in _on_handshake_complete
    raise handshake_exc
  File "/usr/lib/python3.7/asyncio/sslproto.py", line 189, in feed_ssldata
    self._sslobj.do_handshake()
  File "/usr/lib/python3.7/ssl.py", line 763, in do_handshake
    self._sslobj.do_handshake()
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Hostname mismatch, certificate is not valid for 'images.photos.com'. (_ssl.c:1045)
SSL error in data received
protocol: <asyncio.sslproto.SSLProtocol object at 0x7ffbecad8ac8>
transport: <_SelectorSocketTransport closing fd=6 read=idle write=<idle, bufsize=0>>
Traceback (most recent call last):
  File "/usr/lib/python3.7/asyncio/sslproto.py", line 526, in data_received
    ssldata, appdata = self._sslpipe.feed_ssldata(data)
  File "/usr/lib/python3.7/asyncio/sslproto.py", line 189, in feed_ssldata
    self._sslobj.do_handshake()
  File "/usr/lib/python3.7/ssl.py", line 763, in do_handshake
    self._sslobj.do_handshake()
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Hostname mismatch, certificate is not valid for 'images.photos.com'. (_ssl.c:1045)
Error handled

由于未知原因(错误?)aiohttp 甚至在抛出任何异常之前就将错误输出打印到控制台。您可以使用 contextlib.redirect_stderr:

避免临时重定向错误输出
import asyncio
import aiohttp
from ssl import SSLCertVerificationError

import os
from contextlib import redirect_stderr


async def fetch_url(url, client):
    try:

        f = open(os.devnull, 'w')
        with redirect_stderr(f):  # ignore any error output inside context

            async with client.get(url) as resp:
                print(resp.status)
                print(await resp.read())
    except SSLCertVerificationError as e:
        print('Error handled')

# ...

P.S. 我认为你可以使用更常见的异常类型来捕获客户端 errors,例如:

except aiohttp.ClientConnectionError as e:
    print('Error handled')

回溯是由 asyncio 的 SSL 协议实现生成的,它调用了事件循环的 exception handler。通过 transport/protocol 和流层之间的交互迷宫,此异常由事件循环 记录并传播给 API 用户。发生的方式如下:

  • SSL 握手过程中发生异常。
  • SSLProtocol._on_handshake_complete 接收非 None handshake_exc 并将其视为 "fatal error" (在握手上下文中),即调用 self._fatal_error 和 returns.
  • _fatal_error 调用事件循环的异常处理程序来记录错误。通常调用处理程序来处理队列回调中发生的异常,其中不再有调用者将它们传播到,因此它只是将回溯记录到标准错误以确保异常不会静默传递。然而...
  • _fatal_error 继续调用 transport._force_close,后者根据协议调用 connection_lost
  • 流 reader 协议的 connection_lost implementation 将异常设置为流 reader 未来的结果,从而将其传播给流的用户API等待它。

事件循环记录并传递给 connection_lost 的同一异常是错误还是功能并不明显。这可能是 BaseProtocol.connection_lostdefined a no-op 的解决方法,因此额外的日志可确保仅从 BaseProtocol 继承的协议不会消除 SSL 握手期间发生的可能敏感的异常。不管是什么原因,当前的行为导致了 OP 遇到的问题:捕获异常不足以抑制它,回溯仍将被记录。

要解决此问题,可以暂时将异常处理程序设置为不报告的异常处理程序 SSLCertVerificationError:

@contextlib.contextmanager
def suppress_ssl_exception_report():
    loop = asyncio.get_event_loop()
    old_handler = loop.get_exception_handler()
    old_handler_fn = old_handler or lambda _loop, ctx: loop.default_exception_handler(ctx)
    def ignore_exc(_loop, ctx):
        exc = ctx.get('exception')
        if isinstance(exc, SSLCertVerificationError):
            return
        old_handler_fn(loop, ctx)
    loop.set_exception_handler(ignore_exc)
    try:
        yield
    finally:
        loop.set_exception_handler(old_handler)

fetch_url 中的代码周围添加 with suppress_ssl_exception_report() 可以抑制不需要的回溯。

上面的方法有效,但它强烈感觉像是潜在问题的解决方法,而不是正确的 API 用法,所以我在跟踪器中提交了 bug report编辑:问题同时得到解决,问题中的代码不再打印虚假回溯。