Skip to main content
Networking · lesson 2 of 8
Networking beginner Lesson 2 of 8

Ports and Sockets: Connection Refused vs Timed Out

A Python TCP echo server and client in Docker: what a port and socket are, why refused is instant but timed out isn't, and how UDP fails differently.

By SkillByExample · Updated

networking tcp udp sockets ports python docker

An IP address gets a packet to a host. A port gets it to one program on that host. The pair is a socket address, and a TCP connection is two of them joined together. This lesson runs a small TCP echo server and a client in Docker, then breaks the connection on purpose in four different ways. Each way produces a different error after a different wait, and telling them apart is most of network debugging. Tested with Docker Engine 29.8.2 and Python 3.12.15 (python:3.12-slim). Everything runs on one internal Docker network that has no route out of the lab.

A server and a client

# echo_server.py
import socket

srv = socket.create_server(("0.0.0.0", 9000))
print("listening on", srv.getsockname(), flush=True)
while True:
    conn, addr = srv.accept()
    with conn:
        data = conn.recv(1024)
        print("from", addr, "got", data, flush=True)
        conn.sendall(data.upper())

The client takes a host, a port and an optional timeout, and reports what went wrong and how long that took.

# client.py
import socket, sys, time

host, port = sys.argv[1], int(sys.argv[2])
timeout = float(sys.argv[3]) if len(sys.argv) > 3 else 2
start = time.monotonic()
try:
    with socket.create_connection((host, port), timeout=timeout) as s:
        print("local ", s.getsockname())
        print("remote", s.getpeername())
        s.sendall(b"hello")
        print("reply ", s.recv(1024))
except OSError as e:
    print(type(e).__name__, "-", e)
print(f"took {time.monotonic() - start:.2f}s")

Create a lab network with a known subnet and start the server at a fixed address. --internal means the network has no gateway, so nothing in this lesson can leave your machine.

docker network create --internal --subnet 172.28.0.0/24 sock-lab
docker run -d --name server --network sock-lab --ip 172.28.0.10 -v "$PWD":/work -w /work python:3.12-slim python echo_server.py
$ docker run --rm --network sock-lab -v "$PWD":/work -w /work python:3.12-slim python client.py server 9000
local  ('172.28.0.2', 54920)
remote ('172.28.0.10', 9000)
reply  b'HELLO'
took 0.03s
$ docker logs server
listening on ('0.0.0.0', 9000)
from ('172.28.0.2', 54920) got b'hello'

The connection has two ends. The server’s end is the port you chose, 9000. The client’s end is 54920, an ephemeral port the kernel picked for this one connection. The server’s log shows the same pair from the other side. A second client run gets a different ephemeral port, which is how one server port holds thousands of connections at once: each one is a different four-part tuple of client IP, client port, server IP and server port. Docker’s embedded DNS turned the name server into 172.28.0.10.

Failure 1: Connection refused

Same host, a port with nothing behind it:

$ docker run --rm --network sock-lab -v "$PWD":/work -w /work python:3.12-slim python client.py server 9001
ConnectionRefusedError - [Errno 111] Connection refused
took 0.03s

Refused is an answer. The packet crossed the network, the server’s kernel saw no socket listening on 9001, and it replied with a TCP reset straight away. So a refusal in 30 ms tells you a lot: the address is right, the route works, and the problem is the service. Either it isn’t running, it’s on another port, or it’s bound to 127.0.0.1 only. The next lesson shows that reset as a packet.

Failure 2: Timed out

Now an address on the subnet where no container exists:

$ docker run --rm --network sock-lab -v "$PWD":/work -w /work python:3.12-slim python client.py 172.28.0.99 9000
TimeoutError - timed out
took 2.03s

A timeout is silence. Nothing replied, so the client waited for its full two seconds. Silence has more causes than a refusal: no such host, a firewall that drops packets, a broken route, or a server too overloaded to answer. That’s why “it times out” needs more investigation than “it’s refused”. The firewall lesson produces both on purpose from the same server.

Failure 3: wait longer, get a different answer

Give the same dead address ten seconds:

$ docker run --rm --network sock-lab -v "$PWD":/work -w /work python:3.12-slim python client.py 172.28.0.99 9000 10
OSError - [Errno 113] No route to host
took 3.17s

The error changed because something did eventually answer: the client’s own kernel. 172.28.0.99 is on the local subnet, so before sending a TCP packet the kernel asks the subnet “who has 172.28.0.99?” using ARP. After three unanswered ARP requests, about one second apart, it gives up and fails the connect with EHOSTUNREACH. With a two-second timeout your code never saw that, because the timeout fired first. The error you see depends on which clock runs out first. The tcpdump lesson captures those ARP requests with timestamps.

Failure 4: no route at all

An address outside the subnet, on a network with no gateway:

$ docker run --rm --network sock-lab -v "$PWD":/work -w /work python:3.12-slim python client.py 10.9.9.9 9000
OSError - [Errno 101] Network is unreachable
took 0.02s
$ docker run --rm --network sock-lab alpine:3.22 ip route
172.28.0.0/24 dev eth0 scope link  src 172.28.0.2

The route table has a single entry and no default route, which is what --internal gives you. There’s nowhere to send 10.9.9.9, so the kernel fails instantly without a packet leaving the container. On an ordinary network that address would go to the default gateway instead, and you’d get a timeout.

Two programs, one port

Start a second copy of the server in the same container:

$ docker exec server python echo_server.py
Traceback (most recent call last):
  File "/work/echo_server.py", line 4, in <module>
    srv = socket.create_server(("0.0.0.0", 9000))
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/socket.py", line 950, in create_server
    raise error(err.errno, msg) from None
OSError: [Errno 98] Address already in use (while attempting to bind on address ('0.0.0.0', 9000))

A listening port belongs to one socket per address. The first server holds 0.0.0.0:9000, so the second can’t bind it. This is the error behind “my app won’t start after a crash” when the old process is still running. Each container has its own network namespace, so two containers can both listen on 9000. Two processes in the same container can’t.

UDP: no handshake, no guarantee

UDP sends single datagrams with no connection set up first. Send one to the server’s port 9000, where only TCP is listening:

# udp_probe.py
import socket, sys

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(2)
s.connect((sys.argv[1], int(sys.argv[2])))
print("sent", s.send(b"ping"), "bytes")
try:
    print("reply", s.recv(1024))
except OSError as e:
    print(type(e).__name__, "-", e)
$ docker run --rm --network sock-lab -v "$PWD":/work -w /work python:3.12-slim python udp_probe.py server 9000
sent 4 bytes
ConnectionRefusedError - [Errno 111] Connection refused

The send succeeded. UDP has no handshake, so there’s nothing to fail at send time. TCP 9000 and UDP 9000 are separate ports, so the datagram arrived at a port with no UDP listener. The server’s kernel replied with an ICMP “port unreachable” message, and Linux reported it on the next recv. It only does that because the socket is connected. An unconnected UDP socket using sendto would just time out. Silence is the normal UDP failure, which is why UDP protocols such as DNS retry on their own.

When you’re done: docker rm -f server and docker network rm sock-lab.

Practice

  1. Give UDP a listener. Write a UDP echo server for port 9000, run it inside the existing server container next to the TCP one, and probe again. Does the TCP client still work?
Solution
# udp_echo.py
import socket

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("0.0.0.0", 9000))
while True:
    data, addr = s.recvfrom(1024)
    s.sendto(data.upper(), addr)
$ docker exec -d server python udp_echo.py
$ docker run --rm --network sock-lab -v "$PWD":/work -w /work python:3.12-slim python udp_probe.py server 9000
sent 4 bytes
reply b'PING'
$ docker run --rm --network sock-lab -v "$PWD":/work -w /work python:3.12-slim python client.py server 9000
local  ('172.28.0.2', 50838)
remote ('172.28.0.10', 9000)
reply  b'HELLO'
took 0.02s

There’s no Address already in use this time, because a port number is per protocol. TCP 9000 and UDP 9000 are two different sockets, which is how DNS servers listen on both TCP 53 and UDP 53.

  1. Let the kernel choose. Bind a server to port 0 three times and print the port you got. Where have you seen numbers like these before?
Solution
# port_zero.py
import socket

for _ in range(3):
    with socket.create_server(("0.0.0.0", 0)) as srv:
        print("the kernel picked port", srv.getsockname()[1])
$ docker exec server python port_zero.py
the kernel picked port 41521
the kernel picked port 39915
the kernel picked port 45657

Port 0 means “any free port”. The kernel picks one from the ephemeral range, the same range that gave the client its 54920 and 50838. Test suites use this to start servers that never clash. Your numbers will differ.

  1. A fifth failure. Run the client against sevrer (a typo) on port 9000. Which line of client.py raised, and why is it not a connection error?
Solution
$ docker run --rm --network sock-lab -v "$PWD":/work -w /work python:3.12-slim python client.py sevrer 9000
gaierror - [Errno -3] Temporary failure in name resolution
took 0.02s

create_connection resolves the name before it opens any socket, and resolution failed, so no TCP packet was sent at all. gaierror is a subclass of OSError, which is why the client’s except caught it. The “temporary” wording comes from this internal network, where Docker’s resolver has no upstream server to ask about unknown names. The DNS lesson separates the ways resolution fails.

  1. Read the four errors as a table. Without rerunning anything, write down, for refused, timed out, no route to host and network unreachable: did a packet leave the client, did anything answer, and roughly how long did it take?
Solution
ErrorPacket left the client?Who answeredTime in this lab
Connection refusedyes, TCPthe server’s kernel, with a reset0.03 s
timed outARP requests onlynobodythe client’s timeout, 2 s
No route to hostARP requests onlythe client’s own kernel, after ARP gave up3.17 s
Network is unreachablenothe client’s own kernel, from the route table0.02 s

Only a refusal proves the remote host is alive. The other three are all decided on the client’s side of the wire.

Frequently Asked Questions

What is the difference between a port and a socket?
A port is a 16-bit number that picks out one service on a host, like 9000. A socket is one endpoint of a connection, identified by an IP address and a port, and a TCP connection is identified by both endpoints plus the protocol. Many clients can connect to the same server port at once because each connection has a different client address or client port.
What does Connection refused mean?
Connection refused means the packet reached the target host, but nothing was listening on that port, so the host's kernel answered with a TCP reset. It arrives almost instantly. It tells you the network path works and the problem is the service: it isn't running, listens on a different port, or is bound only to 127.0.0.1.
Why does a connection time out instead of being refused?
A timeout means no answer came back at all. The packet was dropped somewhere: a firewall rule that silently drops it, a host that doesn't exist, or a broken route. Your client waits until its own timeout expires. Because silence has many causes, a timeout needs more investigation than a refusal.
What does OSError Errno 113 No route to host mean?
On Linux, Errno 113 (EHOSTUNREACH) usually means the target address is on your local subnet but no machine answered the ARP request asking who owns it, so the kernel gave up after about three seconds. It can also come from a router or firewall replying with an ICMP host-unreachable message. With a short client timeout you see a timeout instead, because the timeout fires first.
Why didn't my UDP send fail when nothing was listening?
UDP has no handshake, so sending a datagram succeeds as soon as it leaves your machine, whether or not anything is listening. If the target replies with an ICMP port-unreachable message, Linux reports it as ConnectionRefusedError on your next receive, but only for a connected UDP socket. If nothing comes back at all, you only see a receive timeout.