# Measure a proxy download without hiding errors

*Disclosure: I run Portproof, a proxy service. This method works with an authenticated HTTP proxy and a test file you control; it does not require a Portproof account.*

A small HTTPS request can succeed through a proxy while a larger download fails. Reporting only the successful check hides the question you were trying to answer: did the intended transfer finish?

Keep four observations together: the proxy's CONNECT response, the destination's HTTP response, the bytes actually received and the elapsed time. The curl exit code adds a fifth check, because an HTTP response can arrive before a transfer fails.

Here is a method for one bounded download. It keeps certificate verification enabled, sends credentials through an in-memory pipe and deletes the downloaded test data afterwards.

## A 200 MB sample and a refusal

On 3 October 2026, a Portproof test recorded the following outcomes. The larger request asked for bytes `0-199999999`: **200,000,000 bytes**, or 200 decimal MB, approximately 190.7 MiB.

| Observation | Earlier download | Separate download to another endpoint |
|---|---:|---:|
| CONNECT response | 200 | 200 |
| Destination response | 403 | 206 |
| curl exit code | 22 | 0 |
| Downloaded bytes | 0 | 200,000,000 |
| File size checked after download | 0 | 200,000,000 |
| Total seconds | 1.438772 | 35.192607 |
| TLS verification result | 0 | 0 |

The first destination refused the request after the tunnel was established. The test recorded that failure, then a separate request to another endpoint completed the requested range. Neither request used automatic retries. Both discarded the test data afterwards.

The completed transfer is one observation on one route at one time. It establishes that this sample finished. It does not establish country, a typical transfer time or future availability. An accompanying small connection check reported `country_verified: false`; no exit address is published here.

## Choose a destination you control

Use an HTTPS test file on an origin you own or have permission to test. Make it at least 200,000,000 bytes long and configure the origin to serve byte ranges. Avoid signed URLs or URLs containing credentials when sharing a report.

The endpoint needs to return `206 Partial Content` for the requested range. If it ignores the range, redirects or returns less data, this script reports an incomplete sample. Confirm the file and range behaviour on your origin before using a metered proxy connection.

The size limit is a limit on the download body, not a bill estimate. Protocol overhead, connection checks and failed attempts may also consume traffic. Leave appropriate headroom in your account. You can start with a smaller authorised sample by changing `MAX_BYTES`; record the change beside the result.

## Run one request, with hidden credentials

You need Python 3.9 or later and curl 8.4.0 or later on macOS or Linux. Check `python3 --version` and `curl --version` first. The recorded local checks of the earlier wrapper revision used Python 3.9.6 and curl 8.7.1. It uses only Python's standard library.

Save this as `measure-proxy.py`. It prompts for the HTTP proxy URL and your HTTPS test-file URL, then asks for the username and password separately with echo disabled. Keep credentials outside both URLs.

```python
#!/usr/bin/env python3
"""One bounded HTTPS download; credentials go to curl through an in-memory pipe."""
import base64
import datetime
import getpass
import json
import os
import re
import shutil
import subprocess
import tempfile
import warnings
from pathlib import Path
from urllib.parse import urlsplit

MAX_BYTES = 200_000_000  # Decimal MB: 200 MB, approximately 190.7 MiB.


def checked_url(value, scheme):
    if any(ord(char) < 33 or ord(char) > 126 for char in value):
        raise ValueError("Use a URL without spaces or control characters.")
    parsed = urlsplit(value)
    if (parsed.scheme != scheme or not parsed.hostname or parsed.username is not None
            or parsed.password is not None or parsed.fragment):
        raise ValueError("Use the required scheme and keep credentials outside URLs.")
    if scheme == "http" and (not parsed.port or parsed.path not in ("", "/") or parsed.query):
        raise ValueError("The proxy must be an HTTP host and explicit port.")
    return value


def config_value(value):
    return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'


def measure(proxy, username, password, target, max_bytes=MAX_BYTES, test_ca=None):
    proxy = checked_url(proxy, "http")
    target = checked_url(target, "https")
    if (not username or not password or ":" in username
            or any(ord(char) < 32 or ord(char) == 127 for char in username + password)):
        raise ValueError("Supply valid separate proxy credentials.")
    curl = shutil.which("curl")
    if not curl:
        raise ValueError("Install curl 8.4.0 or newer.")
    version = subprocess.run([curl, "--disable", "--version"], check=True,
                             capture_output=True, text=True, timeout=5).stdout
    match = re.search(r"curl (\d+)\.(\d+)\.(\d+)", version)
    if not match or tuple(map(int, match.groups())) < (8, 4, 0):
        raise ValueError("curl 8.4.0 or newer is required for the transfer size limit.")
    basic = base64.b64encode((username + ":" + password).encode()).decode()
    config = "\n".join([
        "proxy = " + config_value(proxy),
        'proxy-header = "Proxy-Authorization: Basic ' + basic + '"',
        "url = " + config_value(target), "",
    ])
    started = datetime.datetime.now(datetime.timezone.utc).isoformat()
    with tempfile.TemporaryDirectory(prefix="proxy-sample-") as directory:
        body = Path(directory) / "body.bin"
        command = [curl, "--disable", "--globoff", "--config", "-", "--silent", "--fail",
                   "--noproxy", "", "--proto", "=https", "--retry", "0",
                   "--connect-timeout", "5", "--max-time", "60",
                   "--range", "0-" + str(max_bytes - 1),
                   "--max-filesize", str(max_bytes), "--output", str(body),
                   "--write-out", "%{http_connect} %{response_code} %{size_download} %{time_total} %{ssl_verify_result}"]
        if test_ca is not None:  # Used only by the loopback verification fixture.
            command.extend(["--cacert", str(test_ca)])
        completed = subprocess.run(command, input=config, capture_output=True, text=True,
                                   timeout=65, env={"PATH": os.environ.get("PATH", "")})
        fields = completed.stdout.strip().split()
        if len(fields) != 5:
            raise ValueError("curl did not return the expected diagnostic fields.")
        connect, status, downloaded, seconds, verified = fields
        file_bytes = body.stat().st_size if body.exists() else 0
        report = {"started_at_utc": started, "curl_exit": completed.returncode,
                  "connect_status": int(connect), "target_status": int(status),
                  "download_bytes": int(float(downloaded)), "file_bytes": file_bytes,
                  "total_seconds": float(seconds), "tls_verify_result": int(verified)}
        report["completed_sample"] = (completed.returncode == 0 and connect == "200"
            and status == "206" and verified == "0" and report["download_bytes"] == max_bytes
            and file_bytes == max_bytes)
    return report  # The temporary body is deleted before the caller receives this.


def main():
    proxy = input("HTTP proxy URL, without credentials: ").strip()
    target = input("Your authorised HTTPS test-file URL: ").strip()
    with warnings.catch_warnings():
        warnings.simplefilter("error", getpass.GetPassWarning)
        username = getpass.getpass("Proxy username (hidden): ")
        password = getpass.getpass("Proxy password (hidden): ")
    report = measure(proxy, username, password, target)
    print(json.dumps(report, indent=2))
    raise SystemExit(0 if report["completed_sample"] else 1)


if __name__ == "__main__":
    try:
        main()
    except (ValueError, OSError, subprocess.SubprocessError, getpass.GetPassWarning):
        raise SystemExit("Test stopped. Check configuration and tool versions; no automatic retry.")
```

Run it in an interactive terminal:

```bash
python3 measure-proxy.py
```

For the proxy URL, use the connection host and HTTP port from your account, for example `http://proxy.example.com:8080`. For the target, use your own HTTPS file URL. These are configuration values, not a claim that the example host exists.

The credential-derived Basic header is supplied with curl's `proxy-header` option through `--config -`. It is addressed to the proxy, rather than inserted into a destination header or command argument. The configuration is never written to a file. The payload is held briefly in a temporary directory so its file size can be checked independently, then removed. A privileged local process could still inspect process memory; do not run this on a shared machine you do not trust.

This example uses an HTTP proxy endpoint: gateway authentication happens before TLS to the destination. Destination HTTPS does not encrypt that earlier exchange. Use the intended gateway over a trusted connection; this wrapper does not implement or test an HTTPS proxy endpoint.

The script ignores curl's default configuration, chooses the proxy explicitly and clears `NO_PROXY` exclusions for this request. URL globbing is disabled with `--globoff`, so braces and brackets in the supplied target cannot expand into multiple downloads. There are no redirects or retries. Connection establishment has a five-second limit, and the full curl operation has a sixty-second limit. TLS uses curl's normal trust store. The optional `test_ca` function parameter belongs to the local verification harness, which creates its own certificate; the interactive command does not use it.

## Interpret the whole report

`completed_sample: true` requires all of these: curl exits successfully, CONNECT is 200, the destination returns 206, TLS verification succeeds, and both the transferred byte count and temporary file size match the requested size. A shorter successful response remains an incomplete sample.

If `completed_sample` is false, retain the report and inspect the failing stage before choosing another request. Exit 1 from the Python script means the sample did not satisfy those checks; `curl_exit` is the underlying curl result.

| Report | What it tells you |
|---|---|
| CONNECT 407 or 502; destination 0 | The requested tunnel was not established. Inspect the proxy response and account/client configuration. |
| CONNECT 200; destination 403 | An HTTP refusal arrived after tunnel establishment. Check the destination's permitted access and request requirements. |
| CONNECT 200; destination 0; curl 60 | Certificate verification failed before an HTTP response was received. Check the hostname, certificate chain and intended trust store. |
| CONNECT 200; destination 206; fewer bytes than requested | A partial response alone does not prove the sample completed. |
| curl 63 | curl rejected a body above the configured size limit. Check whether the origin honoured the range. |

The [CONNECT response code](https://curl.se/libcurl/c/CURLINFO_HTTP_CONNECTCODE.html) and the [destination response code](https://curl.se/libcurl/c/CURLINFO_RESPONSE_CODE.html) describe different stages. A zero destination status means curl received no destination HTTP response; it is not an HTTP status sent by the server.

Do not read TLS result 0 in isolation. In the refused-tunnel cases, TLS to the destination never began, so that field alone cannot establish a verified HTTPS exchange. Keep [certificate verification enabled](https://curl.se/docs/sslcerts.html) while investigating.

## Check the interpretation locally

Five earlier loopback checks used an authenticated test proxy and a temporary HTTPS origin. All listeners were local, credentials were synthetic and the proxy accepted only its own test origin. No live gateway or browser was involved.

| Local fixture | CONNECT | Destination | curl exit |
|---|---:|---:|---:|
| Proxy returns an authentication challenge | 407 | 0 | 56 |
| Proxy reports a gateway failure | 502 | 0 | 56 |
| Origin refuses a request inside an established tunnel | 200 | 403 | 0 |
| Origin answers inside an established tunnel | 200 | 200 | 0 |
| Origin certificate is not trusted | 200 | 0 | 60 |

These values were measured with curl 8.7.1. The fixture did not use `--fail`, so its destination 403 still had exit 0. The download wrapper **does** use `--fail`, so its destination 403 has exit 22. Recording the options resolves the apparent disagreement. Other curl versions can report different tunnel-failure exit codes.

The earlier wrapper revision passed seven loopback checks: a complete range, destination refusal, two tunnel refusals, an untrusted certificate, an oversized body and a short body. Those checks used a 4,096-byte bound. They confirm the wrapper's reporting and credential routing; they do not repeat the live 200 MB measurement.

## Keep a useful record

Save the emitted JSON together with your Python/curl versions, UTC date, requested byte range and an ordinary description of the authorised target. Keep usernames, passwords, Basic headers, signed URLs and exit addresses out of a public report.

For a shorter diagnostic, the [curl proxy guide](https://portproof.org/guides/test-a-proxy-with-curl) shows a single-request check. The [CONNECT troubleshooting guide](https://portproof.org/guides/connect-tunnel-failed) helps interpret tunnel failures. Once you have a representative sample, the [bandwidth calculator](https://portproof.org/tools/proxy-bandwidth-calculator) turns your measured bytes into workload estimates; it does not measure success rates for you.

The curl options are documented in the [official manual](https://curl.se/docs/manpage.html). The important habit is to preserve a failed request alongside a successful one and label the scope of each observation.

*Prepared and technically reviewed with AI assistance for Portproof. The measured results are from the specific runs described above; the tests and their limits are stated explicitly. No client or destination performance guarantee follows from them.*

