Repository: urllib3/urllib3

Issue:
Fix `OSError: [Errno 98] Address already in use` in `bind_sockets` call in CI
We often get the following traceback in GitHub Actions:

```
==================================== ERRORS ====================================
______________ ERROR at setup of TestRetryAfter.test_retry_after _______________
Traceback (most recent call last):
  File "/home/runner/work/urllib3/urllib3/dummyserver/testcase.py", line 141, in setup_class
    cls._start_server()
  File "/home/runner/work/urllib3/urllib3/dummyserver/testcase.py", line 129, in _start_server
    app, cls.io_loop, cls.certs, cls.scheme, cls.host
  File "/home/runner/work/urllib3/urllib3/dummyserver/server.py", line 182, in run_tornado_app
    sockets = tornado.netutil.bind_sockets(None, address=host)
  File "/home/runner/work/urllib3/urllib3/.nox/test-3-7/lib/python3.7/site-packages/tornado/netutil.py", line 161, in bind_sockets
    sock.bind(sockaddr)
OSError: [Errno 98] Address already in use
```

I think we hit https://github.com/tornadoweb/tornado/issues/1860. When we request a socket with host `localhost` and port zero (`None` in Python), then Tornado gets a free IPv4 port and requests that same port in IPv6. But that port could easily be taken with IPv6, especially in crowded CI environments.

From Tornado's maintainer in issue above:

> Yeah, putting the whole of bind_sockets in a retry loop seems reasonable, but it's a bit of a special case (we'd only want to retry when port 0 is requested). I'd accept a PR to do that, but my advice would be that whenever you use port 0 you bind to a specific IP address (i.e. either 127.0.0.1 or ::1) instead of a hostname. That will limit you to a single network interface too and avoid this problem.

The problem with requiring 127.0.0.1 is that we have tests where we use both `localhost` and `127.0.0.1` in order to get two hosts.

So I'd be in favor of putting the `bind_sockets` in a retry loop.


Discussion:
I'm ++ on a retry loop for `bind_sockets`, easy fix that will likely work too.

Resolving patch:
diff --git a/dummyserver/server.py b/dummyserver/server.py
--- a/dummyserver/server.py
+++ b/dummyserver/server.py
@@ -9,6 +9,7 @@
 import asyncio
 import concurrent.futures
 import contextlib
+import errno
 import logging
 import os
 import socket
@@ -199,7 +200,24 @@ def run_tornado_app(
     else:
         http_server = tornado.httpserver.HTTPServer(app)
 
-    sockets = tornado.netutil.bind_sockets(None, address=host)  # type: ignore[arg-type]
+    # When we request a socket with host localhost and port zero (None in Python), then
+    # Tornado gets a free IPv4 port and requests that same port in IPv6. But that port
+    # could easily be taken with IPv6, especially in crowded CI environments. For this
+    # reason we put bind_sockets in a retry loop. Full details:
+    #  * https://github.com/urllib3/urllib3/issues/2171
+    #  * https://github.com/tornadoweb/tornado/issues/1860
+    for i in range(10):
+        try:
+            sockets = tornado.netutil.bind_sockets(None, address=host)  # type: ignore[arg-type]
+        except OSError as e:
+            if e.errno == errno.EADDRINUSE:
+                # TODO this should be a warning if there's a way for pytest to print it
+                print(
+                    f"Retrying bind_sockets({host}) after EADDRINUSE", file=sys.stderr
+                )
+                continue
+        break
+
     port = sockets[0].getsockname()[1]
     http_server.add_sockets(sockets)
     return http_server, port


Test patch: