このエントリーをはてなブックマークに追加

urllib2でtimeoutを捕まえる

urllib2を使っていて、timeoutを入れたくなりました。で、timeoutが発生したらリトライをする、といった処理をしたいのですが、このtimeoutがどういう例外なのかが分かりませんでした。

世界のtk0miya先生は urlib2.URLErrorで、messageに "timed out"と書かれている例外が飛んでくる、とおっしゃっていました。

しかし、手元で試したところ、 socket.timeout が飛んできておりました。

はてな?

手元は2.7.2 on Linux(ubuntu)、世界の小宮先生は2.7.1とのこと。

書いていたのはこういうコードでした。

try:
    f = urllib2.urlopen(req, timeout=timeout)
    body = f.read()
except socket.timeout, e:
    print("TIMEOUT!")

コードを追う

ということで、urllib2のコードを追っていきます。

def urlopen(url, data=None, timeout=socket._GLOBAL_DEFAULT_TIMEOUT):
    global _opener
    if _opener is None:
        _opener = build_opener()
    return _opener.open(url, data, timeout)

でtimeoutを設定したのち、openしてます。実際のopenはこのあたりです。

try:
    h.request(req.get_method(), req.get_selector(), req.data, headers)
except socket.error, err: # XXX what error?
    h.close()
    raise URLError(err)

ほー。socket.errorであればURLErrorで投げなおしているようです。であれば、URLErrorがということ?

socketを追っていきます。

Lib/socket.py はcreate_connection()とwrite()、readinto()が呼ばれており、実態はModules/socketmodule.cへ。

ここを見ると、

static PyObject *socket_timeout;

(略)

socket_timeout = PyErr_NewException("socket.timeout",
                                    socket_error, NULL);
Py_INCREF(socket_timeout);
PyModule_AddObject(m, "timeout", socket_timeout);

とあり、socket.timeoutという例外を作っています。baseクラスはsocket_errorですね。

実際に投げているところはこんな感じでいろいろなところで投げています。

if (timeout == 1) {
    PyErr_SetString(socket_timeout, "timed out");
    return NULL;
}

ふむふむ。

基本に戻れ

と、ここで世界の小宮先生から「traceは?」との指摘が。確かに。

見てみるとこんな感じでした。

Traceback (most recent call last):
  File "hoge.py", line 62, in _callAPI
    body = f.read()
  File "/usr/lib/python2.7/socket.py", line 351, in read
    data = self._sock.recv(rbufsize)
  File "/usr/lib/python2.7/httplib.py", line 561, in read
    s = self.fp.read(amt)
  File "/usr/lib/python2.7/socket.py", line 380, in read
    data = self._sock.recv(left)
timeout: timed out

あ!urllib2を通っていない?

確認してみると世界の小宮先生の場合、 urlopen でtimeoutしているとのこと。一方、今回はreadのところでtimeoutしています。

つまり、

  • urlopenでtimeoutの場合

    urllib2.URLError が飛んでくる

  • read()でtimeoutの場合

    socket.timeout が飛んでくる

のではないかと考えられます。

検証

socket.pyのreadintoを見てみると、

if self._timeout_occurred:
    raise IOError("cannot read from timed out object")
while True:
    try:
         return self._sock.recv_into(b)
    except timeout:
        self._timeout_occurred = True
        raise
    except InterruptedError:
        continue
    except error as e:
        if e.args[0] in _blocking_errnos:
            return None
        raise

となっています。timeoutを捕まえてそのままraiseしています。ん?whiletrueに入る前にtimeoutが起きるとIOErrorが変えるのか。これは今は関係ないけどちょっと気になる点。

一方、create_connection

for res in getaddrinfo(host, port, 0, SOCK_STREAM):
    af, socktype, proto, canonname, sa = res
    sock = None
    try:
        sock = socket(af, socktype, proto)
        if timeout is not _GLOBAL_DEFAULT_TIMEOUT:
            sock.settimeout(timeout)
        if source_address:
            sock.bind(source_address)
        sock.connect(sa)
        return sock

    except error as _:
        err = _
        if sock is not None:
            sock.close()

if err is not None:
    raise err
else:
    raise error("getaddrinfo returns an empty list")

とあり、socket.errorを捕まえてそのまま投げています。

と、ここでtimeoutしていたら、世界の小宮先生が続きを書いてしまわれました。

どこでこういう変更があったか

ということで、乗りかかった船で、どこでどういう変更があったかを追跡してみる。といっても、先生の後を追っているだけだけど。

で、調べてみると71523: b66bbbdc7abdで urllib2 に 変更があったみたいだ。

変更前

try:
    h.request(req.get_method(), req.get_selector(), req.data, headers)
    try:
        r = h.getresponse(buffering=True)
    except TypeError: #buffering kw not supported
        r = h.getresponse()
except socket.error, err: # XXX what error?
    raise URLError(err)
finally:
    h.close()

変更後

try:
    h.request(req.get_method(), req.get_selector(), req.data, headers)
except socket.error, err: # XXX what error?
    h.close()
    raise URLError(err)
else:
    try:
        r = h.getresponse(buffering=True)
    except TypeError: # buffering kw not supported
        r = h.getresponse()

b66bbbdc7abd以前であれば、request()でもgetresponse()でも、socket.errorはすべてURLErrorになって投げ直されています。

ところが、変更以後はh.request()で発生したsocket.errorはURLErrorとなりますが、それ以降のh.getresponse()で発生したsocket.errorはそのまま上がっていくことになります。

h.request()はhttplib中でsocket.sendall()を呼んでおり、h.getresponse()はsocket.read()を呼んでいます。

h.request()でtimeout、つまり、sendでtimeoutすると、URLErrorとなりますが、一旦送りきってしまえばURLErrorとはなりません。…ほんと?

なお、f.read()部分ではurllib2を通らず、httplib経由でsocket.pyを叩くことになりますので、このURLErrorでの投げ直しは行われず、そのままsocket.errorになります。

小宮先生にも検証してもらって、確証が持てました。

というわけで、結論です。

timeout時に起きる例外

urlopen()中でHTTP Request送信中 urlopen()中でHTTPヘッダ受信中 f.read()でHTTP Body部受信中
Python2.7.1 urllib2.URLError urllib2.URLError socket.timeout
Python2.7.2 urllib2.URLError urllib2.URLError socket.timeout
Python2.7.1 urllib2.URLError socket.timeout socket.timeout

とはいえ、結局はurllib2.URLErrorとsocket.timeoutの両方を捕まえておけばいい、と言うコードになるわけですが。