Software Engineering WikiSE Wiki

HTTP and curl

Use curl to test HTTP endpoints, break request time into DNS, TCP, TLS and server phases, script calls safely, and read what status codes mean.

Reviewed MarkdownEdit

On this page

Cheatsheet#

TaskCommand
Response headers only (sends HEAD)curl -I https://example.com
Headers of a real GETcurl -sS -D - -o /dev/null https://example.com
Follow redirectscurl -L https://example.com
Fail with a non-zero exit on 4xx/5xxcurl -fsS https://example.com
Show request and response headers, TLS detailscurl -v https://example.com
Status code onlycurl -o /dev/null -sw '%{http_code}\n' https://example.com
Timing breakdowncurl -o /dev/null -sw '@curl-format.txt' https://example.com
POST JSONcurl --json @body.json https://api.example.com/items
Upload a file as multipart formcurl -F file=@report.csv https://api.example.com/upload
Bearer tokencurl -H "Authorization: Bearer $API_TOKEN" https://api.example.com
Connect to a chosen IP, keep Host and SNIcurl --resolve api.example.com:443:192.0.2.10 https://api.example.com
Skip certificate verificationcurl -k https://example.com (diagnosis only)
Force an HTTP versioncurl --http1.1 URL, curl --http2 URL, curl --http3 URL
Through a proxycurl -x http://proxy.example.com:3128 https://example.com
Retry transient failurescurl --retry 3 --retry-delay 2 --retry-connrefused URL
Bound the timecurl --connect-timeout 3 --max-time 10 URL
Keep cookies between callscurl -c jar.txt -b jar.txt URL

--json (curl 7.82.0 and later) sends the body with Content-Type: application/json and Accept: application/json. --http3 needs a curl built with HTTP/3 support; check curl --version for HTTP3 in the features line.

A request that is failing#

# Handshake, request headers (>), response headers (<), status
curl -sSv https://api.example.com/health -o /dev/null

# Where the time goes
curl -o /dev/null -sw 'code=%{http_code} dns=%{time_namelookup} conn=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' https://api.example.com/health

# Bypass DNS and hit one backend, keeping the Host header and TLS SNI
curl -sv --resolve api.example.com:443:192.0.2.10 https://api.example.com/health -o /dev/null
FailureWhere it happened
Could not resolve host (exit 6)DNS; see DNS
Connection refused (exit 7)Host reached, nothing listening on that port, or a firewall sent a reset
Connection timed out (exit 28 with --connect-timeout)Packets dropped: firewall, security group, routing; see iproute2
SSL certificate problem (exit 60)Incomplete chain, name mismatch, expiry, or CA missing from this trust store; see TLS
SSL_ERROR_SYSCALL or unexpected eof during handshakeSomething in the path closed the connection: a proxy, a load balancer without the certificate, or a port that does not speak TLS
Works with -kThe problem is certificate validation, not connectivity
Long time_appconnectSlow TLS handshake: high latency (several round trips), a large chain, or a CPU-starved terminator
Long time_starttransfer, everything else shortThe server is slow to produce the first byte
Empty reply from server (exit 52)The server or a middlebox closed the connection without sending a response

Timing#

All time_* write-out values are measured from the start of the transfer, so each includes the phases before it.

cat > curl-format.txt <<'EOF'
     dns  %{time_namelookup}s
 connect  %{time_connect}s
     tls  %{time_appconnect}s
 waiting  %{time_starttransfer}s
   total  %{time_total}s
    size  %{size_download} bytes
    code  %{http_code}
 version  %{http_version}
EOF

curl -o /dev/null -s -w '@curl-format.txt' https://api.example.com/health
     dns  0.012345s
 connect  0.030112s
     tls  0.071870s
 waiting  0.180553s
   total  0.181204s
    size  15 bytes
    code  200
 version  2

Subtract to isolate each phase:

PhaseFormula
DNStime_namelookup
TCP handshaketime_connect - time_namelookup
TLS handshaketime_appconnect - time_connect (0 for plain HTTP)
Server processing plus one round triptime_starttransfer - time_appconnect
Body transfertime_total - time_starttransfer

When a proxy is in use, time_connect is the connection to the proxy.

# Ten samples, sorted, to separate a slow server from a slow tail
for i in $(seq 10); do curl -o /dev/null -sw '%{time_total}\n' https://api.example.com/health; done | sort -n

Useful flags#

FlagEffect
-f / --failExit 22 on HTTP 400 and above, without printing the body
--fail-with-bodyExit 22 on HTTP errors but still output the body (7.76.0 and later)
-s / -SSilent; -sS stays silent but still prints errors
-LFollow redirects; --max-redirs bounds it (default 50)
-iInclude response headers in the output
-D -Dump response headers to stdout while the body goes elsewhere
--compressedAsk for a compressed response and decode it
-A, -eSet User-Agent and Referer
--data-binarySend the payload exactly as given
-GMove -d data into the query string and send GET
--url-queryAppend a URL-encoded query parameter (7.87.0 and later)
-wWrite-out template: timings, sizes, redirect URL, status
-ZRun several URLs in parallel
--trace-ascii -Full dump of everything sent and received

Sending data#

# JSON body; -H is needed because -d defaults to form encoding
curl -X POST https://api.example.com/items \
  -H 'Content-Type: application/json' \
  -d '{"name":"widget","qty":4}'

# JSON from a file with --json (sets Content-Type and Accept)
curl --json @payload.json https://api.example.com/items

# Multipart form upload
curl -F 'file=@report.csv' -F 'note=monthly' https://api.example.com/upload

# URL-encoded query string
curl -G https://api.example.com/search --data-urlencode 'q=name with spaces' --data-urlencode 'limit=10'

# PATCH with a merge patch
curl -X PATCH -H 'Content-Type: application/merge-patch+json' -d '{"qty":5}' https://api.example.com/items/1

# PUT a file
curl -T ./artifact.tgz https://uploads.example.com/path/

-d sends POST with Content-Type: application/x-www-form-urlencoded unless you override the header. -d @file strips carriage returns, newlines and null bytes from the file. --data-binary @file sends it byte for byte, which matters for signed payloads, YAML and NDJSON.

TLS checks#

# Negotiated protocol, cipher, certificate subject, issuer and expiry
curl -svI https://example.com 2>&1 | grep -E 'SSL connection|subject:|issuer:|expire date'

# Force a TLS version range
curl --tlsv1.3 -sI https://example.com
curl --tlsv1.2 --tls-max 1.2 -sI https://example.com

# Trust a private CA for this call
curl --cacert ca.pem https://internal.example.com

# Client certificate (mTLS)
curl --cert client.pem --key client.key https://mtls.example.com

-k shows the problem is certificate validation and nothing more. Do not leave it in a script: it accepts any certificate, including an attacker’s. curl does not check revocation unless you ask with --cert-status (OCSP stapling). For chain repair and openssl s_client, see TLS.

Proxies#

curl -x http://proxy.example.com:3128 https://api.example.com
export https_proxy=http://proxy.example.com:3128 no_proxy=.example.com,localhost,10.0.0.0/8
curl --proxy-user "alice:$PROXY_PASSWORD" -x http://proxy.example.com:3128 https://api.example.com
curl -x socks5h://localhost:1080 https://api.example.com   # socks5h: the proxy resolves DNS
curl -sv https://api.example.com -o /dev/null 2>&1 | grep -iE 'CONNECT|proxy'

curl reads lowercase http_proxy only; HTTPS_PROXY and https_proxy both work. no_proxy entries match the host itself or any name inside that domain (example.com matches www.example.com). CIDR ranges in no_proxy work from curl 7.86.0; other tools (Python requests, Go, Java) each have their own rules, so test the client you actually run. A proxy in the path shows as a CONNECT in verbose output for HTTPS, and often adds Via or X-Cache headers. See Squid for the proxy side.

--proxy-user on the command line is visible in ps. For anything shared, put credentials in a .netrc file or a config file read with -K.

Redirects#

-L follows Location headers. For 301, 302 and 303 curl may switch a POST to GET on the next request; 307 and 308 keep the method and body. curl drops Authorization and Cookie headers when a redirect leaves the original origin, unless --location-trusted is set.

# Every hop with its status and Location
curl -sIL https://example.com | grep -iE '^HTTP/|^location:'

# Final URL after all redirects
curl -o /dev/null -sw '%{url_effective}\n' -L https://example.com

Caching#

A cache (browser, CDN, Squid) stores a response according to its Cache-Control and reuses it while its age is below max-age; Age says how long the cache has held it. A stale copy is revalidated with a conditional request, and a 304 answer costs no body.

DirectiveWhereEffect
max-age=NResponseFresh for N seconds from the origin’s Date
s-maxage=NResponseSame, for shared caches only; overrides max-age there
no-cacheResponseStore, but revalidate with the origin before every reuse
no-storeResponseNever write to disk or memory; for credentials and personal data
privateResponseBrowser may cache, shared caches must not
stale-while-revalidate=NResponseServe stale for N seconds while refreshing in the background
Vary: Accept-EncodingResponseCache key includes that request header; Vary: * disables caching
no-cacheRequestForce revalidation; what a browser hard reload sends

no-cache means “always check”, not “do not cache”; no-store keeps a response out of caches. Validation pairs ETag with If-None-Match or Last-Modified with If-Modified-Since.

# See what a cache would do with this response
curl -sSI https://example.com/app.js | grep -iE '^(cache-control|etag|last-modified|age|vary|expires|x-cache):'

# Conditional GET: 304 if the ETag still matches
curl -so /dev/null -w '%{http_code}\n' -H 'If-None-Match: "5d8c72a5edda8d6a"' https://example.com/app.js

# Download again only when the ETag changed (7.68.0 and later)
curl -sS --etag-compare etag.txt --etag-save etag.txt -o app.js https://example.com/app.js

A CDN or proxy adds Age and a hit or miss header (X-Cache, CF-Cache-Status); two requests in a row with a rising Age prove the cache holds the object.

CORS#

CORS is enforced by browsers, not servers. A script on https://app.example.com calling https://api.example.com gets the response only if the API answers with Access-Control-Allow-Origin matching the page’s origin (or *). curl ignores CORS, so “works in curl, fails in the browser” with an Access-Control console error is a missing header, not a connectivity problem.

Any method other than GET, HEAD or POST, a non-form Content-Type, or a custom header such as Authorization triggers a preflight: the browser sends OPTIONS with Origin, Access-Control-Request-Method and Access-Control-Request-Headers, and sends the real request only if the answer allows them.

# Reproduce a preflight; the server must return 2xx and the Access-Control-* headers
curl -sS -X OPTIONS https://api.example.com/items \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: PUT' \
  -H 'Access-Control-Request-Headers: authorization,content-type' \
  -D - -o /dev/null | grep -iE '^(HTTP/|access-control-)'
Response headerMeaning
Access-Control-Allow-OriginThe one origin allowed, or *; must echo the exact origin when credentials are used
Access-Control-Allow-MethodsMethods permitted after preflight
Access-Control-Allow-HeadersRequest headers permitted after preflight
Access-Control-Allow-Credentials: trueCookies and Authorization may be sent; incompatible with *
Access-Control-Expose-HeadersResponse headers script may read beyond the safe set
Access-Control-Max-AgeSeconds the browser may cache the preflight result

A response that varies Access-Control-Allow-Origin per request must send Vary: Origin, or a shared cache serves one origin’s answer to another. Preflights carry no cookies or Authorization, so an endpoint that rejects unauthenticated OPTIONS breaks CORS.

Authentication#

SchemeRequest headercurl
BasicAuthorization: Basic base64(user:pass)-u "alice:$PASSWORD" or --basic
BearerAuthorization: Bearer <token>--oauth2-bearer "$API_TOKEN" or -H
DigestChallenge-response with a server nonce--digest -u "alice:$PASSWORD"
Negotiate (Kerberos / SPNEGO)Authorization: Negotiate <ticket>--negotiate -u :
AWS Signature v4Signed headers and canonical request--aws-sigv4 aws:amz:ap-southeast-2:s3 -u "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY"
Mutual TLSClient certificate in the handshake--cert client.pem --key client.key; see TLS

Basic credentials are base64, not encrypted; never send them over plain HTTP. With -u and no scheme flag curl sends Basic immediately; --anyauth requests first and picks the strongest scheme from WWW-Authenticate at the cost of a round trip.

# Credentials from ~/.netrc (mode 0600, one line: machine api.example.com login alice password ...)
curl -n https://api.example.com/me                     # --netrc-file for another path

# OAuth2 client-credentials flow, then use the token
token=$(curl -fsS -u "$CLIENT_ID:$CLIENT_SECRET" -d 'grant_type=client_credentials' https://auth.example.com/oauth/token | jq -r .access_token)
curl -fsS --oauth2-bearer "$token" https://api.example.com/items

-u on the command line is visible through ps and lands in shell history. Prefer -n, a config file passed with -K, or --variable %API_TOKEN --expand-header 'Authorization: Bearer {{API_TOKEN}}' (curl 8.3.0 and later), which reads the value from the environment.

HTTP/2 and HTTP/3#

HTTP/1.1 sends one request at a time per TCP connection. HTTP/2 multiplexes streams over one TLS connection and compresses headers; HTTP/3 does the same over QUIC (UDP 443), removing transport-level head-of-line blocking. The version is chosen through ALPN (h2, http/1.1) in the TLS handshake and, for HTTP/3, by an Alt-Svc: h3=":443" header on an earlier response.

# Which version was negotiated
curl -so /dev/null -w '%{http_version}\n' https://example.com

# ALPN offer and answer
curl -sv https://example.com -o /dev/null 2>&1 | grep -iE 'ALPN|using HTTP'

# HTTP/2 without TLS, for a backend behind a terminating proxy (no upgrade, straight h2c)
curl --http2-prior-knowledge http://localhost:8080/health

# Try HTTP/3 and fall back, or insist on it
curl --http3 -sI https://example.com
curl --http3-only -so /dev/null -w '%{http_version}\n' https://example.com

Load balancers usually terminate HTTP/2 at the edge and speak HTTP/1.1 to backends, so a 421 or a header-size error may come from the edge rather than the application. HTTP/3 needs UDP 443 open on every firewall in the path; when --http3-only fails but --http3 works, the fallback masked a blocked UDP path.

Cookies#

curl has no cookie engine until you turn it on with -b or -c. -b reads cookies (a file, or a literal name=value string), -c writes every cookie the server sets to a jar in Netscape format. Using the same file for both keeps a session across calls.

# Log in, keep the session cookie, use it
curl -sS -c jar.txt -d "username=alice&password=$PASSWORD" https://app.example.com/login -o /dev/null
curl -sS -b jar.txt https://app.example.com/dashboard

# Send a literal cookie without a file
curl -b 'session=abc123; theme=dark' https://app.example.com/


# What the server set, including attributes
curl -sSI https://app.example.com/login | grep -i '^set-cookie'

A Secure cookie is never sent over http://; Domain=example.com sends it to every subdomain, no Domain restricts it to the host that set it. SameSite=Lax (the browser default) withholds the cookie on cross-site POSTs, a common cause of “logged out after the identity provider redirect”; curl ignores it. -b with an empty file still enables the engine, so cookies set during a redirect chain reach later hops.

Status codes in practice#

CodeWhat it usually means
301 / 308Permanent redirect; 308 must keep the method and body
302 / 307Temporary redirect; 307 must keep the method and body
304Not Modified: the client’s cached copy is current
400Malformed request: check Content-Type and body encoding
401Missing or invalid credentials; the WWW-Authenticate header says which scheme
403Authenticated but not allowed, or blocked by a WAF or IP allowlist
404Wrong path, or the route exists only on another virtual host
405Right path, wrong method; Allow lists the valid ones
408Server timed out waiting for the request
409Conflict: optimistic concurrency failure, duplicate create
413 / 414Content Too Large / URI Too Long for the server or a proxy in front
421Misdirected Request: a reused HTTP/2 connection reached a server that does not serve this host
429Rate limited; honour Retry-After
499Not standard; nginx logs it when the client closed the connection first
500Unhandled application error; the application logs have the detail
502A proxy could not connect to the backend or got an invalid response
503No healthy backend, maintenance, or load shedding; may carry Retry-After
504A proxy connected, but the backend did not answer within the proxy’s timeout

A 502 and a 504 from the same proxy point at different problems. 502 is connection-level (refused, reset, bad response); 504 is a timeout, and the timeout that fired is set in the proxy’s configuration, not the application’s. See RFC 9110 for definitions.

Using curl in scripts#

if ! body=$(curl -fsS --max-time 10 --retry 3 --retry-connrefused https://api.example.com/health); then
  printf 'health check failed\n' >&2
  exit 1
fi
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
code=$(curl -sS -o "$tmp/body" -D "$tmp/headers" -w '%{http_code}' --max-time 30 \
  --json @payload.json https://api.example.com/items)
case $code in
  2*)  ;;
  429) sleep "$(awk 'tolower($1) == "retry-after:" {print $2}' "$tmp/headers" | tr -d '\r')" ;;
  *)   printf 'unexpected %s: %s\n' "$code" "$(cat "$tmp/body")" >&2; exit 1 ;;
esac

Always set --max-time. Without it a server that accepts the connection and never answers hangs the job indefinitely. --retry retries only transient errors: timeouts and HTTP 408, 429, 500, 502, 503, 504, 522 and 524. Add --retry-connrefused for refused connections, or --retry-all-errors to retry anything. See Bash for error handling around these calls.

Retries back off from one second and double unless --retry-delay fixes the interval; --retry-max-time 60 caps the whole sequence. -w '%{json}' prints every write-out variable as one JSON object for logging; %{exitcode}, %{errormsg} and %{num_retries} record what went wrong and how many retries it took.

curl -sS -o /dev/null -w '%{json}\n' --retry 3 --max-time 10 https://api.example.com/health \
  | jq '{code: .http_code, total: .time_total, retries: .num_retries, ip: .remote_ip, err: .errormsg}'

Troubleshooting#

SymptomLikely causeCheck
curl -I returns 405 or different headers from GETServer handles HEAD differentlycurl -sS -D - -o /dev/null URL sends a GET
JSON API returns 400 or 415-d sent form encodingUse --json or set Content-Type
Signature or checksum mismatch on upload-d @file stripped newlinesUse --data-binary @file
401 after a redirectAuthorization dropped on a cross-origin redirectcurl -v shows the second request; use the final URL directly
Works in a browser, fails in curlMissing intermediate certificate, cookies, or a WAF blocking the User-Agentcurl -v TLS lines; compare headers from browser dev tools
Works with IP, fails with hostname (or the reverse)Virtual host or SNI routing--resolve keeps name and SNI while choosing the IP
Intermittent 502 behind a load balancerBackend closes idle keep-alive connections before the balancer doesBackend idle timeout must exceed the balancer’s
Unexpected proxy use or bypasshttp_proxy, https_proxy, no_proxy in the environmentenv | grep -i _proxy; curl -v for CONNECT
Hangs with no outputNo --max-time; firewall dropping packets--connect-timeout 3 -v to see which phase stalls
Compressed garbage in outputServer sent compressed body without being asked, or --compressed omittedcurl -sI for Content-Encoding; add --compressed
Browser console shows blocked by CORS policy, curl worksMissing Access-Control-Allow-Origin, or the preflight OPTIONS is rejectedReplay the OPTIONS request with Origin and Access-Control-Request-Method headers
Stale content after a deployLong max-age at a CDN or in the browser, or Vary missingcurl -sSI for Cache-Control, Age, X-Cache; purge or version the asset name
Login works, next request is unauthenticatedCookie engine off, or cookie marked Secure sent over http://Use -c jar.txt -b jar.txt on both calls; check Set-Cookie attributes
401 with WWW-Authenticate: DigestBasic sent to a Digest-only server--digest -u, or --anyauth
--http3 works, --http3-only failsUDP 443 blocked somewhere in the pathTest with --http3-only; open UDP 443 or accept the fallback
--retry did not retryError is not on curl’s transient list (for example 404, 401 or connection refused)Add --retry-connrefused or --retry-all-errors; check %{num_retries}

Oneliners#

# Response headers of a GET, sorted
curl -sS -D - -o /dev/null https://example.com | sort

# Poll until a service is healthy
until curl -fsS --max-time 2 http://localhost:8080/health >/dev/null; do sleep 1; done

# 50 requests, 10 at a time, count the status codes
seq 50 | xargs -P10 -I{} curl -o /dev/null -sw '%{http_code}\n' https://api.example.com/ | sort | uniq -c

# Compare JSON config between two environments (needs jq)
diff <(curl -s https://staging.example.com/api/config | jq -S .) <(curl -s https://prod.example.com/api/config | jq -S .)

# Resume a partial download
curl -C - -O https://example.com/big.iso

# Send an HMAC-signed webhook; the secret comes from the environment
body='{"event":"test"}'; sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | awk '{print $NF}'); curl -X POST -H "X-Signature: sha256=$sig" -H 'Content-Type: application/json' --data-binary "$body" https://api.example.com/hook

# Negotiated HTTP version and scheme
curl -so /dev/null -w '%{http_version} %{scheme}\n' https://example.com

# Plain HTTP virtual host test against an IP
curl -H 'Host: api.example.com' http://192.0.2.10/health

# Slowest of several endpoints
for u in "${urls[@]}"; do printf '%s %s\n' "$(curl -o /dev/null -sw '%{time_total}' "$u")" "$u"; done | sort -rn | head

# Every write-out variable as JSON, for a log line
curl -sS -o /dev/null -w '%{json}\n' https://api.example.com/health

# Response headers as JSON (7.83.0 and later)
curl -sS -o /dev/null -w '%{header_json}\n' https://example.com | jq '.["cache-control"]'

# Which IP and port curl actually talked to (after DNS and redirects)
curl -so /dev/null -w '%{remote_ip}:%{remote_port} %{http_code}\n' -L https://example.com





# Talk to a service on a Unix socket (Docker, Podman, systemd-style daemons)
curl -sS --unix-socket /var/run/docker.sock http://localhost/version






# HEAD every URL in a file, 10 at a time, print code and URL
xargs -a urls.txt -P10 -I{} curl -so /dev/null -w '%{http_code} {}\n' -I {}





# Bearer token from the environment without putting it on the command line (8.3.0 and later)
curl --variable %API_TOKEN --expand-header 'Authorization: Bearer {{API_TOKEN}}' https://api.example.com/me

Scripts#

Sample an endpoint N times and print percentile latency per phase, so a slow DNS resolver or TLS terminator is separated from a slow application.

#!/usr/bin/env bash
# usage: http-latency.sh URL [samples]
set -euo pipefail
url=${1:?url required}; n=${2:-20}
tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT
for _ in $(seq "$n"); do
  curl -sS -o /dev/null --max-time 15 \
    -w '%{time_namelookup} %{time_connect} %{time_appconnect} %{time_starttransfer} %{time_total} %{http_code}\n' \
    "$url" >>"$tmp" || true
done
printf '%-8s %8s %8s %8s\n' phase p50 p90 max
for col in 1:dns 2:tcp 3:tls 4:ttfb 5:total; do
  i=${col%%:*}; name=${col#*:}
  sort -k"$i" -n "$tmp" | awk -v i="$i" -v name="$name" \
    '{v[NR]=$i} END {printf "%-8s %8.3f %8.3f %8.3f\n", name, v[int(NR*0.5)+1], v[int(NR*0.9)+1], v[NR]}'
done
printf 'codes: '; awk '{print $6}' "$tmp" | sort | uniq -c | tr '\n' ' '; echo

Check a list of URLs and report any whose status, latency or certificate expiry crosses a threshold, for a cron job that emails or pages.

#!/usr/bin/env bash
# usage: http-check.sh urls.txt   (one URL per line, # comments allowed)
set -euo pipefail
list=${1:?url list required}; max_ms=${MAX_MS:-2000}; min_days=${MIN_CERT_DAYS:-14}
rc=0
while IFS= read -r url; do
  [[ -z $url || $url == \#* ]] && continue
  out=$(curl -sS -o /dev/null --max-time 10 -w '%{http_code} %{time_total} %{exitcode} %{errormsg}' "$url" || true)
  read -r code total exit_code msg <<<"$out"
  ms=$(awk -v t="$total" 'BEGIN {printf "%d", t * 1000}')
  if [[ $exit_code != 0 ]]; then printf 'FAIL %s curl(%s): %s\n' "$url" "$exit_code" "$msg"; rc=1; continue; fi
  [[ $code == 2* || $code == 3* ]] || { printf 'FAIL %s status %s\n' "$url" "$code"; rc=1; }
  (( ms <= max_ms )) || { printf 'SLOW %s %sms\n' "$url" "$ms"; rc=1; }
  if [[ $url == https://* ]]; then
    host=${url#https://}; host=${host%%/*}; host=${host%%:*}
    exp=$(echo | openssl s_client -servername "$host" -connect "$host:443" 2>/dev/null | openssl x509 -noout -enddate | cut -d= -f2)
    days=$(( ($(date -d "$exp" +%s) - $(date +%s)) / 86400 ))
    (( days >= min_days )) || { printf 'CERT %s expires in %d days\n' "$url" "$days"; rc=1; }
  fi
done <"$list"
exit "$rc"

Further reading#