>_C2CZ
Serverless · Self-Hosting · Security · Independent Ops

curl (52) Empty reply from server: Causes and Fix

When curl prints curl: (52) Empty reply from server, the TCP connection to the host and port succeeded — and then the peer closed the connection before sending a single byte of HTTP. That narrow window is the whole story: something accepted your connection, your request went out, and the other side hung up with nothing to show for it. This post is the triage I run when a poller, a deploy script, or a cron job starts dying with 52 — what the error proves, which failure mode each neighboring error code points to, and the exact fixes, all reproduced and verified on 2026-09-05 with curl 8.14.1 and OpenSSL 3.5.6 on Debian 13.

I chased this one through a weekend because a health-check script on a fleet of Raspberry Pi kiosks kept failing against our self-hosted status API — but only from the kiosks, never from my laptop, and only some of the time. The generic advice online ("clear your DNS", "use HTTPS", "update curl") did not survive contact with the actual transcripts, so I built a small lab that reproduces the error on demand. You can run the same lab in two minutes, and it will tell you more than an hour of Googling.

TL;DR

  • 52 means zero HTTP bytes. curl connected, sent the request, and the server closed the connection cleanly (a FIN) without writing any response. The official name is CURLE_GOT_NOTHING; curl's docs say: "Nothing was returned from the server, and under the circumstances, getting nothing is considered an error."
  • It is not a timeout, not a reset, and not DNS. Timeout is exit 28, a reset is exit 56, refused is exit 7. Each neighbor points to a different failure mode; if you conflate them you will fix the wrong layer.
  • The most common cause is a scheme or port mismatch: your http:// URL pointed at a listener that only speaks TLS. The TLS-only listener cannot parse your plaintext request, so it closes — and you get 52, verified below with a real TLS server.
  • Second most common: an app or upstream that is not healthy. A listener accepted the connection, but the process behind it crashed, is restart-looping, or is a load-balancer backend with no healthy target — so the socket closes before a response exists.
  • Triage in this order: read curl -v; test the scheme with openssl s_client; run curl against localhost on the server itself; then check server and proxy logs. The fix falls out of whichever layer closed the connection.

What curl exit code 52 actually means

Every curl error code is a statement about how far the request got. Error 7 means you never established TCP. Error 28 means the connection stayed open and nothing arrived. Error 56 means the peer actively reset the connection (RST). Error 52 sits in its own category: the TCP connection was established, your request was fully sent, and then the peer sent a FIN — an orderly "I'm done" — without ever sending one byte of HTTP back.

Here is the exact transcript, reproduced against a server that accepts a connection, reads the request, and closes without writing anything:

$ curl -v http://127.0.0.1:8201/
*   Trying 127.0.0.1:8201...
* Connected to 127.0.0.1 (127.0.0.1) port 8201
* using HTTP/1.x
> GET / HTTP/1.1
> Host: 127.0.0.1:8201
> User-Agent: curl/8.14.1
> Accept: */*
>
* Request completely sent off
* Empty reply from server
* shutting down connection #0
curl: (52) Empty reply from server

Read the middle of that transcript. Connected to ... port 8201 proves a listener accepted the TCP handshake. Request completely sent off proves curl finished sending the request. Then, instead of a response, the peer closed. The FIN arrived with zero payload bytes, so libcurl reports Empty reply from server and exits 52. Whatever closed that connection was capable of accepting TCP but produced no HTTP at all — that fact alone eliminates most innocent explanations and points at a small set of causes.

💡 The distinction matters for automation: 52 is retryable only when the cause is transient (a restart window, a failover). If the cause is a permanent scheme mismatch, retrying just multiplies useless connections. Diagnose first, then decide the retry policy — the last section of this post has a wrapper that does exactly that.

Reproduce the empty reply: a two-minute lab

You do not need a broken server to see this error — that is the trick that makes it debuggable. The server below accepts a connection, waits for the HTTP request, and closes the socket without writing a byte. That is the minimal behavior that produces exit 52:

#!/usr/bin/env python3
# Reproduce "curl: (52) Empty reply from server": accept, read the request,
# then close with zero response bytes.
import socket

HOST, PORT = "127.0.0.1", 8201

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv:
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind((HOST, PORT))
    srv.listen(5)
    print(f"listening on {HOST}:{PORT}", flush=True)
    while True:
        conn, _ = srv.accept()
        with conn:
            conn.recv(4096)   # wait for the request to arrive, then close (FIN)

Run that in one terminal and curl -v http://127.0.0.1:8201/ in another. You will get the exact 52 transcript above. Now change one line — send an SSH-style banner before closing — and curl reports something completely different. That contrast is the entire diagnostic: when the peer sends any bytes at all, even non-HTTP ones, you do not get 52.

I ran a matrix of six misbehaving servers plus two scheme mismatches to map each error code to its cause. Every row below is an executed result, not a guess:

What the server didcurl exitWhat curl printed
Accepted, read request, closed (FIN, zero bytes)52Empty reply from server
Sent an SSH-style banner, then closed1Received HTTP/0.9 when not allowed
Accepted, read request, held the connection open28 (with --max-time)Operation timed out ... with 0 bytes received
Sent a status line and partial headers, then closed18transfer closed with 100 bytes remaining to read
Answered normally (HTTP 200)0ok
Read the request, then forced a TCP RST56Recv failure: Connection reset by peer
TLS-only listener, hit with plain http://52Empty reply from server
Plain-HTTP server, hit with https://35SSL routines::wrong version number

Two rows are the ones that matter. A TLS-only listener hit with plain HTTP gives you 52 — the listener cannot parse "GET / HTTP/1.1" as a TLS handshake, so it closes before any HTTP can exist. A plain HTTP server hit with HTTPS gives you 35 — the client's TLS ClientHello lands on a socket that answers in plain HTTP, and the handshake fails. Same misconfiguration, opposite direction, opposite error code. If you see 35, your URL has https:// where the port expects http://. If you see 52, your URL has http:// where the listener expects TLS, or the listener is healthy at the TCP layer but dead at the application layer.

Here is the TLS-only reproduction, verified with OpenSSL's built-in test server:

# generate a throwaway self-signed cert, then start a TLS-only listener
$ openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
    -keyout key.pem -out cert.pem -subj "/CN=localhost"
$ openssl s_server -accept 127.0.0.1:9443 -cert cert.pem -key key.pem -www -quiet

# in another terminal: plain http:// against the TLS-only listener
$ curl -v http://127.0.0.1:9443/
*   Trying 127.0.0.1:9443...
* Connected to 127.0.0.1 (127.0.0.1) port 9443
...
* Empty reply from server
curl: (52) Empty reply from server

# the same listener over https:// (self-signed, hence -k) answers fine
$ curl -ksS -o /dev/null -w '%{http_code}\n' https://127.0.0.1:9443/
200

That one experiment explains a huge share of real-world 52 reports. A reverse proxy, a load balancer in TCP passthrough, or a port-forward that lands on a TLS-speaking backend all behave like this listener: they accept the connection and forward your plaintext, the TLS backend cannot parse it, and the connection dies before a response byte exists. From the client's seat it is indistinguishable from a dead server — because at the HTTP layer, it is.

Triage: who closed the connection?

Error 52 always means the TCP endpoint you reached sent the FIN. So the first question is not "what is wrong with curl" — it is which endpoint closed, and why did it close without writing. Work this list top to bottom; each step takes seconds and eliminates a layer.

1. Read the full curl -v output, not the last line. Confirm the "Connected to" line and look for a TLS handshake section. If you see TLS handshake lines (the * TLSv1.3 (OUT), TLS handshake, Client hello block), the connection was TLS from the start and the close came after the handshake — that is an application-layer story, not a scheme mismatch. If there is no TLS section at all and the close comes right after "Request completely sent off", you are looking at plain HTTP hitting something that wanted TLS, or an app that died before responding.

2. Check whether the port expects TLS at all. The one-liner below tests the listener without sending an HTTP request. I verified it against a real public host on 2026-09-05:

# does the port speak TLS? (works for any TLS service; -servername for SNI)
$ echo | openssl s_client -connect curl.se:443 -servername curl.se 2>/dev/null \
    | openssl x509 -noout -subject
subject=CN=curl.se

If s_client completes a handshake and prints a certificate, the port expects TLS — and if your curl command used http://, you have already found the cause. If s_client hangs or fails with a protocol error, the port is not a TLS service, and the 52 is coming from the application layer or something in the path.

3. Split client from path from server. Run the same curl command on the server itself against http://localhost:PORT/. Localhost works but the remote URL gives 52 → the problem is between you and the server: a load balancer, a firewall, a port-forward, a VPN. Localhost also gives 52 → the server's own listener or app is the problem, so go read its logs. I covered the sibling failure at the SSH layer — a server closing the connection before the protocol exchange finished — in ssh_exchange_identification: Connection closed by host; the layer differs, the triage shape is identical.

4. Check what owns the port and what the app logged. On the server, ss -tnlp | grep :443 (or lsof -iTCP:443 -sTCP:LISTEN on BSD/macOS) shows the process behind the listener. Then look at the application log around the failure timestamps: a crash or restart at the same second as the 52s is the answer. Nginx and other TLS web servers log plaintext-on-a-TLS-port attempts — the current nginx source (ngx_http_request.c) logs client sent plain HTTP request to HTTPS port — but note that nginx itself answers those with a real HTTP error response, so if you are proxying through nginx you will usually see an HTTP status, not 52. 52 means the closer could not produce HTTP at all: a raw TLS listener, a TCP-passthrough proxy whose upstream is gone, or a dead application process.

Fix by cause

Cause 1: http:// pointed at a TLS-only listener

The fix is usually one character: use https://. Confirm with step 2 above, then switch the scheme in the URL, the script, or the monitoring config. When you control the endpoint, make the misconfiguration impossible: if clients will always use http:// on port 80, terminate TLS at the edge and let the backend speak plain HTTP to the proxy — or redirect port 80 to 443 so a wrong-scheme request becomes a redirect instead of a dead connection. The standard nginx pattern for the second option:

server {
    listen 80;
    listen [::]:80;
    server_name api.example.com;
    return 301 https://$host$request_uri;
}

⚠️ The trap is the TCP-passthrough topology: an L4 load balancer or an iptables DNAT rule forwards raw bytes to a TLS backend. The load balancer cannot tell the client "use https" — it is not an HTTP device. If clients hit the LB with http://, the TLS backend closes the connection and the client sees 52 every time. Fix the topology so the scheme the client speaks matches the listener it reaches: terminate TLS at the LB, or point the LB at a plain-HTTP backend.

Cause 2: an app that accepts but crashes before responding

A listener that accepts TCP while the worker behind it is restart-looping produces exactly this signature: connect succeeds, request goes out, socket closes, zero bytes. Community reports of "my app keeps restarting on port 80" describe precisely this — the port is bound, so there is no connection-refused, but nothing ever answers. Check the app's logs for the crash, and look at container restart counters if you are on Docker. A container that flips between Up and Restarting will serve 52s during every restart window — this is the same class of daemon-health failure I covered in Docker driver failed programming external connectivity, just one layer up the stack.

While you fix the crash, make the restart window visible to clients instead of silent. A restart policy plus a health check that gates traffic (Docker Compose keys, verified against the current compose specification):

services:
  api:
    image: your-api:1.4.2
    restart: unless-stopped
    healthcheck:
      # NOTE: the image must contain curl; swap to "wget -q -O-" on alpine
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 20s

Cause 3: a load balancer or proxy with no healthy upstream

An HTTP load balancer returns 502 when its upstream is down, so 52 through an L4/TCP load balancer usually means the backend died and the LB is closing connections that it cannot serve. Check the target group's health status in the LB console (or haproxy stats, or whatever your edge runs) before touching the app. A widely reported cloud variant: a managed load balancer accepts the TCP connection, probes the backend, finds nothing listening, and closes — the client sees 52 while the "server" looks fine from outside. The GCP community reports around this error describe exactly that: an instance firewall or a missing backend listener, with the LB translating the failure into a bare connection close.

Related but distinct: Cloudflare 524 is the timeout variant of the same story — the origin did not answer in time — while 52 is the immediate-close variant. If your edge gives you 524s sometimes and 52s other times, you have one underlying origin-health problem expressing itself two ways.

Cause 4: connection reuse across an idle close

Clients that pool connections — libcurl handles reused across requests, monitoring agents, anything that keeps a keep-alive connection open — can hit 52 when the server or an intermediary closes the idle connection and the client's next request lands on the dead socket. The close races the request: the request goes out on a connection that is already half-closed, and no response ever comes back. This one shows up as "works for hours, then 52, then works again." If you control the client, sending Connection: close on each request eliminates the reuse path, at the cost of a new TCP handshake per request; if the connection pool is the point, treat 52 as a retry signal and reconnect once. The retry policy in the next section handles this automatically.

Retry policy: when 52 is worth retrying

Retrying a permanent scheme mismatch is theater. Retrying a transient 52 — an app restart window, an LB failover, a pooled connection closed underneath you — is how production monitors survive blips without paging anyone. The distinction is why you diagnose first and automate second.

curl has a built-in retry, but by default it only retries a narrow set of conditions. The --retry-all-errors flag widens it to connection-level failures including 52. I verified the behavior against the empty-reply lab server: curl retried twice and still exited 52 after the third attempt, which is exactly what you want — bounded retries, honest final status:

$ curl --retry 2 --retry-all-errors --retry-delay 1 --max-time 5 \
    http://127.0.0.1:8201/
curl: (52) Empty reply from server
curl: (52) Empty reply from server
curl: (52) Empty reply from server
$ echo $?
52

⚠️ Retry only idempotent requests. --retry-all-errors re-sends whatever you asked it to send — safe for GET and HEAD, dangerous for a POST that charges a card or creates a record. For non-idempotent requests, design your own retry with idempotency keys or a queue, or do not retry at all.

For scripts that need backoff plus a clear fatal signal, this wrapper retries only the connection-level codes (28 timeout, 52 empty reply, 56 reset), backs off 1s, 2s, 4s, and returns curl's exit code when it gives up. It was executed and verified against healthy, empty-reply, and silent servers on 2026-09-05:

#!/usr/bin/env bash
# Retry an idempotent GET on connection-level failures.
# Usage: curl_retry <url> [attempts=4] [initial_delay=1] [max_time=15]
curl_retry() {
  local url="$1"
  local attempts="${2:-4}"
  local delay="${3:-1}"
  local max_time="${4:-15}"
  local body rc i
  for ((i = 1; i <= attempts; i++)); do
    body=$(curl -fsS --max-time "$max_time" "$url" 2>/dev/null) && {
      printf '%s' "$body"
      return 0
    }
    rc=$?
    if ((rc == 28 || rc == 52 || rc == 56)) && ((i < attempts)); then
      sleep "$delay"
      delay=$((delay * 2))
      continue
    fi
    echo "curl_retry: fatal after $i attempt(s), curl exit $rc" >&2
    return "$rc"
  done
}

# usage in a health check
body=$(curl_retry "https://api.example.com/healthz" 4 1 10) || {
  echo "health endpoint down" >&2
  exit 1
}
printf '%s\n' "$body"

If the wrapper returns 52 after all attempts, the endpoint is not transiently sick — it is structurally broken, and the fix is Cause 1, 2, or 3 above, not another retry. Page on the fatal path, and keep the wrapper's stderr line in whatever you use for alerting so the on-call engineer sees curl exit 52 immediately.

Verification: prove the fix

After the fix, the same command that produced the empty reply must produce a real HTTP response. Run it verbose once to confirm the shape, then non-verbose to confirm the exit code:

# before: empty reply, exit 52
$ curl -sS http://127.0.0.1:8201/ ; echo "exit=$?"
curl: (52) Empty reply from server
exit=52

# after: real response, exit 0
$ curl -sS http://127.0.0.1:8205/ ; echo "exit=$?"
ok
exit=0

Then verify the surrounding system, not just one request: run the failing command five times in a row, confirm the server log shows no crash or restart at those timestamps, and check the LB health status if one is in the path. For the kiosk fleet that started this, the fix was Cause 1 with a twist — the status API had moved behind a TLS-terminating reverse proxy, and the kiosk poller still used http://. One character in the poller config, and the 52s stopped. If you hit this in a cron job or a deploy pipeline, the same decision tree applies: read the transcript, test the scheme, split local from remote, read the server logs, then fix the layer that actually closed the connection.

Quick reference for the error codes around 52: 7 connection refused — nothing is listening; 28 timeout — the connection stayed open but silent; 35 TLS handshake failure — wrong scheme for the port (https:// to plain HTTP); 52 empty reply — connected, request sent, peer closed with zero HTTP bytes (http:// to TLS, dead app, dead LB upstream, pooled-connection race); 56 reset — the peer aborted with RST (crash or firewall); 18 partial transfer — the response started and died mid-body. Get those six straight and the empty reply stops being a mystery.

C2CZ

Hyper-specific engineering guides: Cloudflare Workers, Turso, self-hosting, network security, offensive security, independent operations.