Fixing MaxRetryError and Connection Refused in Python urllib3

intermediate🌐 Networking2026-07-09| Python 3.x, Linux (Ubuntu/Debian/CentOS), macOS, Docker containers using requests or urllib3 libraries.

Error Message

urllib3.exceptions.MaxRetryError: HTTPConnectionPool(host='...', port=80): Max retries exceeded with url: /api/endpoint (Caused by NewConnectionError('<urllib3.connection.HTTPConnection object>: Failed to establish a new connection: [Errno 111] Connection refused'))
#python#requests#urllib3#networking#docker

The Context

We’ve all been there: your code runs perfectly on your laptop, but the moment it hits the staging server, the logs explode. I recently encountered this while deploying a Python worker designed to fetch data from a REST API. The logs were flooded with MaxRetryError, specifically pointing to [Errno 111] Connection refused.

This error is urllib3’s way of saying it tried to reach the server multiple times and failed every single time. It isn't a random glitch. It’s a persistent failure to establish a handshake. While requests is the library you likely imported, urllib3 is the engine under the hood doing the heavy lifting—and it’s the one throwing the tantrum.

Debug Process

A Connection refused error is actually helpful because it narrows the problem down to the transport layer. Usually, the service is either dead, listening on the wrong "door," or blocked by a gatekeeper. Here is how to track it down.

1. The "Is it Plugged In?" Check

First, verify the target service is actually alive. Log into the server hosting the API and check if the process is bound to the expected port (e.g., port 80 or 8080). I usually rely on ss or netstat for this:

# Look for listeners on port 80
sudo ss -tulpn | grep :80

If this command returns an empty result, your backend service likely crashed or failed to start. On macOS, you might see [Errno 61] Connection refused instead of 111, but the diagnostic steps remain the same.

2. The 127.0.0.1 Trap

This is a classic configuration blunder. If your API is bound to 127.0.0.1, it’s only talking to itself. Any request coming from a Docker container or a different VM will be rejected immediately. To fix this, ensure your service binds to 0.0.0.0, which tells it to listen on all available network interfaces.

3. DNS and IP Mismatches

Sometimes the hostname resolves to an old IP address. Use dig +short your-api-host to see where your traffic is actually headed. If the IP doesn't match your current server, your Python script is knocking on the wrong door.

Solutions

Solution 1: Container Networking

In Docker environments, localhost is relative. If your script is in Container A and the API is in Container B, localhost inside Container A refers to itself, not the API. Use the internal service name defined in your docker-compose.yml file. Docker’s internal DNS will handle the routing automatically.

Solution 2: Implement Exponential Backoff

If the network is just jittery—perhaps due to a 50ms latency spike or a quick service reload—don't let the default settings kill your process. Use a Retry object to give the connection a second (or third) chance. This snippet adds a "backoff factor," which forces the script to wait longer between each attempt (e.g., 0.6s, 1.2s, 2.4s).

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

def get_robust_session(retries=3, backoff=0.3):
    session = requests.Session()
    # We only retry on specific status codes or connection errors
    retry_strategy = Retry(
        total=retries,
        backoff_factor=backoff,
        status_forcelist=[429, 500, 502, 503, 504],
        allowed_methods=["HEAD", "GET", "OPTIONS"]
    )
    adapter = HTTPAdapter(max_retries=retry_strategy)
    session.mount("http://", adapter)
    session.mount("https://", adapter)
    return session

try:
    client = get_robust_session()
    response = client.get('http://api.internal/v1/data', timeout=5)
    print(f"Status: {response.status_code}")
except Exception as e:
    print(f"Request failed: {e}")

Solution 3: Audit Firewall Rules

If the service is definitely running on 0.0.0.0 but you're still locked out, check ufw or iptables. A single missing rule can block your client's IP range.

When dealing with complex VPCs or subnets, I use a Subnet Calculator to verify that my CIDR blocks (like 10.0.0.0/24) actually overlap with the client's IP. It’s an easy way to ensure your firewall whitelist isn't too narrow.

Verification

Before touching your Python code again, run a curl test from the client environment. It’s the fastest way to isolate code issues from infrastructure issues:

curl -I http://your-host:80/api/endpoint
  • HTTP 200/404: The network is fine; the issue is likely in your Python logic.
  • Connection Refused: The port is closed or the service is down.
  • Operation Timed Out: A firewall is silently dropping your packets.

Lessons Learned

  • The stack trace is a map: MaxRetryError is just the wrapper. Always scroll to the bottom to find the Errno.
  • Timeouts are mandatory: Never make a request without a timeout=X parameter. Without it, your application could hang indefinitely if the server accepts the connection but never sends data.
  • Log the IP: When debugging, log the actual IP address the hostname resolves to. It saves hours of chasing ghost servers.

Related Error Notes