<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Portproof Guides]]></title><description><![CDATA[Portproof Guides]]></description><link>https://portproof.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 24 Oct 2026 03:37:03 GMT</lastBuildDate><atom:link href="https://portproof.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Keep SMS polling separate from delivery]]></title><description><![CDATA[Build an offline Python read model for SMS status. Keep failed polls, empty snapshots, message counts and uncertain operations separate without resending.]]></description><link>https://portproof.hashnode.dev/sms-polling-vs-delivery</link><guid isPermaLink="true">https://portproof.hashnode.dev/sms-polling-vs-delivery</guid><category><![CDATA[Python]]></category><category><![CDATA[Testing]]></category><dc:creator><![CDATA[portproofas]]></dc:creator><pubDate>Fri, 23 Oct 2026 00:53:23 GMT</pubDate><content:encoded><![CDATA[<p>Prepared with AI assistance for Portproof. All events and counts below are fictional. The example is an offline teaching model, not a Portproof API client; it sends no messages, reads no real inbox and makes no purchase.</p>
<p>A status screen can lose useful information when it uses one “success” flag for everything. A number can be reported active while a message fetch fails. A message fetch can succeed with an empty snapshot. An operation can have an uncertain outcome while the interface is still able to refresh an existing record.</p>
<p>Keep those facts separate. In particular, a failed poll must not overwrite a previously successful reading with zero messages or imply that another purchase should be made.</p>
<h2>Give each fact its own history</h2>
<p>A small read model can retain three independent streams:</p>
<table>
<thead>
<tr>
<th>Stream</th>
<th>What its value means</th>
<th>What it does not establish</th>
</tr>
</thead>
<tbody><tr>
<td>Operation outcome</td>
<td>A reported <code>confirmed</code> or <code>unknown</code> result for the same acquisition operation.</td>
<td>An active resource, a delivered message or permission to make a second purchase.</td>
</tr>
<tr>
<td>Resource snapshot</td>
<td>The last successfully read state, such as <code>active</code> or <code>expired</code>.</td>
<td>Whether a separate message refresh succeeded.</td>
</tr>
<tr>
<td>Message snapshot</td>
<td>The count in the last successfully read message snapshot.</td>
<td>All-time message count, future delivery, account acceptance or continued access after expiry.</td>
</tr>
</tbody></table>
<p>For resource and message reads, retain both the last successful observation time and the latest attempt time/result. If a refresh fails after a successful read, the previous value is still a historical fact. Show it with its age and failed-refresh indicator, rather than presenting it as a new current reading.</p>
<p>An empty successful snapshot and a failed fetch are different. So are a reported expiry and an unreadable status. Do not infer any of them from another stream.</p>
<h2>An offline reducer with explicit limits</h2>
<p>Save the complete example as <code>polling_model.py</code>. It uses Python 3.10 or newer and only the standard library. The names are this article's teaching format, not a claim about any service's endpoint, event schema or complete lifecycle.</p>
<p>The <code>at</code> field orders observations within each stream. Supply a trustworthy observation timestamp with an explicit offset. Do not stamp a cached old result with a new clock value and treat it as a fresh source fact. An actual integration needs the service's revision/timestamp rules, or serialised reads with suitable stale-response protection; this reducer cannot create an authoritative ordering for overlapping responses.</p>
<pre><code class="language-python">"""A fictional local read model; it sends no requests or messages."""
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
import json


def instant(value):
    try:
        date = datetime.fromisoformat(value.replace("Z", "+00:00"))
        if date.tzinfo is None or date.utcoffset() is None:
            raise ValueError
        return date.astimezone(timezone.utc)
    except (AttributeError, TypeError, ValueError):
        raise ValueError("Use a timestamp with an explicit UTC offset") from None


@dataclass(frozen=True)
class Snapshot:
    value: object = None
    succeeded_at: str | None = None
    attempted_at: str | None = None
    latest_attempt_ok: bool | None = None


@dataclass(frozen=True)
class State:
    operation: Snapshot = field(default_factory=Snapshot)
    resource: Snapshot = field(default_factory=Snapshot)
    messages: Snapshot = field(default_factory=Snapshot)


def apply_event(state, event):
    kind, at = event["kind"], instant(event["at"])
    if kind in ("operation_confirmed", "operation_unknown"):
        stream, ok, value = "operation", True, kind.removeprefix("operation_")
    elif kind in ("resource_ok", "messages_ok", "resource_error", "messages_error"):
        stream, outcome = kind.split("_")
        ok = outcome == "ok"
        value = event.get("value") if ok else None
        if ok and stream == "resource" and value not in ("pending", "active", "expired", "failed"):
            raise ValueError("Unrecognised resource state in this teaching model")
        if ok and stream == "messages" and (type(value) is not int or value &lt; 0):
            raise ValueError("Use a non-negative snapshot count")
    else:
        raise ValueError("Unrecognised event")
    previous = getattr(state, stream)
    if stream == "operation" and previous.value == "confirmed" and value == "unknown":
        return state
    if previous.attempted_at:
        earlier = instant(previous.attempted_at)
        if at &lt; earlier:
            return state
        if at == earlier:
            if previous.latest_attempt_ok == ok and (not ok or previous.value == value):
                return state
            raise ValueError("Conflicting events at the same stream timestamp")
    stamp = at.isoformat()
    if ok:
        current = Snapshot(value, stamp, stamp, True)
    else:
        current = replace(previous, attempted_at=stamp, latest_attempt_ok=False)
    return replace(state, **{stream: current})


def view(state):
    def show(snapshot):
        return {"last_successful_value": snapshot.value,
                "last_successful_at": snapshot.succeeded_at,
                "last_attempt_at": snapshot.attempted_at,
                "latest_attempt_succeeded": snapshot.latest_attempt_ok}
    return {"operation": {"reported_outcome": state.operation.value,
                          "reported_at": state.operation.attempted_at},
            "resource": show(state.resource),
            "message_snapshot_count": show(state.messages)}


if __name__ == "__main__":
    # Fictional events only. An empty snapshot does not authorise a resend.
    events = [
        {"kind": "operation_confirmed", "at": "2026-10-23T09:00:00Z"},
        {"kind": "resource_ok", "value": "active", "at": "2026-10-23T09:01:00Z"},
        {"kind": "messages_ok", "value": 0, "at": "2026-10-23T09:02:00Z"},
        {"kind": "messages_error", "at": "2026-10-23T09:03:00Z"},
    ]
    state = State()
    for event in events:
        state = apply_event(state, event)
    print(json.dumps(view(state), indent=2))
</code></pre>
<p>Run it locally:</p>
<pre><code class="language-sh">python3 polling_model.py
</code></pre>
<p>The fictional sequence records an acknowledged operation, an active resource, an empty successful message snapshot and then a failed message refresh. The output retains the zero <strong>from the earlier successful snapshot</strong>, its original timestamp, and <code>latest_attempt_succeeded: false</code> for the later attempt. It does not claim that the failed attempt found no messages.</p>
<p>The model intentionally performs no polling, retries, expiry calculation, sending or financial action. It replaces snapshot counts rather than adding them, so replaying the same snapshot does not manufacture more messages.</p>
<h2>Handle order, duplicates and uncertainty deliberately</h2>
<p>A repeated identical observation at the same stream timestamp leaves the state unchanged. An older observation cannot replace a newer one in that stream. Conflicting resource/message observations at the same timestamp raise an error for review instead of choosing whichever value is more convenient. The resource and message watermarks are independent: a newer message snapshot does not make an earlier resource observation irrelevant.</p>
<p>For the same acquisition operation, a later ambiguous report does not erase a definite earlier confirmation in this model. A confirmation still establishes neither resource activation nor message arrival. If the initial operation is unknown, keep its reference and use its documented recovery/status process. Do not turn a missing response into a second purchase.</p>
<p>This distinction matters beyond SMS. <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2">HTTP semantics</a> separates idempotent methods from operations that cannot safely be retried merely because the connection failed. A read-model reducer does not make a side-effecting request idempotent. Any real polling loop also needs the service's access rules, bounded attempts and its rate-limit guidance; this example supplies none of those policies.</p>
<h2>Make the screen say what was observed</h2>
<p>Useful labels include “Last successful message check”, “Latest refresh failed” and “Reported expiry”. Avoid an unqualified “No messages” based on an error, or “Current” beside a reading whose freshness is unresolved.</p>
<p>Keep message contents and verification codes out of ordinary logs. The example stores only a fictional snapshot count and timestamps; it does not need a phone number, a code or a customer identifier. An actual application still needs authorised access, appropriate retention and the receiving service's rules.</p>
<p>A reported expired resource can coexist with a retained historical message snapshot. That does not promise the inbox remains accessible, that a later message will arrive, or that the same number can be kept. Product term and account-recovery requirements belong in the buyer's decision before purchase.</p>
<h2>What was checked</h2>
<p>On 23 October 2026, fourteen offline unittest cases passed with Python 3.12.14. They cover unknown initial values, independent resource/message facts, empty successful snapshots, failed-refresh retention, later arrivals, duplicate/older events, same-time conflicts, independent watermarks, uncertain/confirmed operations, expiry, invalid values and timezone normalisation. The standalone example was run, and the displayed Python block was compared with that executed file.</p>
<p>The tests establish the behaviour of this local model. They do not test real delivery, receiving-service acceptance, API polling limits, a supplier integration or customer outcomes.</p>
<p>For the product decision, <a href="https://portproof.org/guides/sms-activation-vs-rental">activation versus rental</a> explains how the access terms differ. <a href="https://portproof.org/guides/sms-number-expiry-account-recovery">Number expiry and account recovery</a> covers dependencies that can remain after the paid term. Check the actual supported service and duration in the <a href="https://portproof.org/sms">SMS catalogue</a>; a previous delivered message is not a guarantee of another one.</p>
]]></content:encoded></item><item><title><![CDATA[Keep an eSIM data journal you can check]]></title><description><![CDATA[Check two eSIM data readings with a local Python tool. Preserve source, SIM, period and timestamps; reject resets, stale snapshots and unknown units.]]></description><link>https://portproof.hashnode.dev/esim-data-journal</link><guid isPermaLink="true">https://portproof.hashnode.dev/esim-data-journal</guid><category><![CDATA[Python]]></category><category><![CDATA[Testing]]></category><dc:creator><![CDATA[portproofas]]></dc:creator><pubDate>Fri, 23 Oct 2026 00:53:18 GMT</pubDate><content:encoded><![CDATA[<p>Prepared with AI assistance for Portproof. The readings below are fictional. The Python checks run locally and do not test a handset, a mobile network or a provider's accounting.</p>
<p>A travel-data estimate starts with two readings that mean the same thing. Subtracting a phone's current-period counter from a plan's remaining allowance will still produce a number. It will not produce a useful usage sample.</p>
<p>Keep a small journal before building a dashboard or choosing a travel allowance. Record where a reading came from, which SIM and period it describes, and when its source was last updated. Then reject pairs whose meaning has changed.</p>
<h2>Record the counter's identity</h2>
<p>The <a href="https://support.apple.com/en-us/109323">iPhone usage reference</a> describes current-period statistics, resetting them, and viewing usage for the selected data line. <a href="https://support.google.com/pixelphone/answer/2819524?hl=en">Pixel's usage guidance</a> lets you choose a time period and notes that carrier measurements can differ from the phone's. These are reasons to retain measurement context, rather than assume every screen has the same counter.</p>
<p>Use short local labels such as <code>travel-data</code>; a journal does not need a phone number, account identifier, installation QR code or location history.</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>What it records</th>
</tr>
</thead>
<tbody><tr>
<td><code>source</code></td>
<td><code>phone</code> or <code>plan_used</code>: a cumulative used-data counter. Remaining allowance is a different quantity.</td>
</tr>
<tr>
<td><code>line</code></td>
<td>Your local label for the selected line. Keep different lines separate.</td>
</tr>
<tr>
<td><code>period</code></td>
<td>The known measurement period or reset segment, such as <code>sample-A</code>.</td>
</tr>
<tr>
<td><code>captured_at</code></td>
<td>When you copied the reading, with an explicit UTC offset.</td>
</tr>
<tr>
<td><code>snapshot_at</code></td>
<td>The time the source reading actually describes, when known. Do not replace an old source timestamp with the time you refreshed the page.</td>
</tr>
<tr>
<td><code>value</code>, <code>unit</code></td>
<td>The reported value and its known basis. The example accepts bytes, decimal MB or decimal GB explicitly.</td>
</tr>
<tr>
<td><code>reset</code></td>
<td>Whether a reset occurred at this reading: <code>yes</code> or <code>no</code>.</td>
</tr>
</tbody></table>
<p>For a phone counter you read directly, <code>snapshot_at</code> can be that observation's time. For a provider reading with an older update time, retain that older time. If you cannot establish the source time or unit basis, leave the pair unresolved. Do not label a screen's ambiguous “GB” as decimal merely to satisfy the script.</p>
<h2>Select two readings before calculating a delta</h2>
<p>Keep the full journal, but export exactly two selected rows for this check. The following <code>sample.csv</code> describes one fictional phone counter, on one line, within one period. Both units explicitly use decimal bytes; the example does not assert how a particular phone labels its units.</p>
<pre><code class="language-csv">source,line,period,captured_at,snapshot_at,value,unit,reset
phone,travel-data,sample-A,2026-10-20T09:00:00Z,2026-10-20T09:00:00Z,1000,decimal_MB,no
phone,travel-data,sample-A,2026-10-22T09:00:00Z,2026-10-22T09:00:00Z,1.6,decimal_GB,no
</code></pre>
<p>The difference is 0.6 decimal GB between the two source snapshots. This is a calculation from invented inputs, not a measured consumption rate or a recommended allowance.</p>
<p>The checker below rejects a changed source, line or period; a reset at the end; an old or non-advancing source snapshot; and a decreasing counter. A blank counter remains unknown. A genuine zero delta requires two known, advancing snapshots with equal values.</p>
<h2>A complete local checker</h2>
<p>Save this as <code>journal.py</code>. It uses Python 3.10 or newer and only the standard library. It has no network, account or installation step.</p>
<pre><code class="language-python">"""Check two selected cumulative-usage readings; no network access."""
import argparse
import csv
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
import json
import re


UNITS = {"bytes": Decimal(1), "decimal_MB": Decimal(1000000),
         "decimal_GB": Decimal(1000000000)}


def timestamp(value):
    try:
        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
        if parsed.tzinfo is None or parsed.utcoffset() is None:
            raise ValueError
        return parsed.astimezone(timezone.utc)
    except (AttributeError, TypeError, ValueError):
        raise ValueError("invalid_time") from None


def reading(row):
    required = ("source", "line", "period", "captured_at", "snapshot_at",
                "value", "unit", "reset")
    if any(not isinstance(row.get(key), str) or not row[key].strip()
           for key in required):
        raise ValueError("missing_value")
    if row["source"] not in ("phone", "plan_used"):
        raise ValueError("unknown_source")
    if row["reset"] not in ("yes", "no"):
        raise ValueError("unknown_reset")
    if row["unit"] not in UNITS:
        raise ValueError("unknown_unit")
    number = row["value"].strip()
    # Bound significant digits so conversion and subtraction remain exact
    # within Decimal's default precision; never round an oversized input.
    if (not re.fullmatch(r"[0-9]+(?:\.[0-9]+)?", number)
            or len(number.replace(".", "")) &gt; 18):
        raise ValueError("invalid_counter")
    try:
        count = Decimal(number) * UNITS[row["unit"]]
    except InvalidOperation:
        raise ValueError("invalid_counter") from None
    if count != count.to_integral_value():
        raise ValueError("fractional_byte")
    captured, snapshot = timestamp(row["captured_at"]), timestamp(row["snapshot_at"])
    if snapshot &gt; captured:
        raise ValueError("future_snapshot")
    return captured, snapshot, count


def assess_pair(before, after, *, max_age_seconds):
    if type(max_age_seconds) is not int or max_age_seconds &lt;= 0:
        raise ValueError("Choose a positive integer snapshot-age policy")
    try:
        a_captured, a_snapshot, a_count = reading(before)
        b_captured, b_snapshot, b_count = reading(after)
    except ValueError as error:
        return {"usable": False, "reason": str(error)}
    for key in ("source", "line", "period"):
        if before[key] != after[key]:
            return {"usable": False, "reason": "changed_" + key}
    if after["reset"] == "yes":
        return {"usable": False, "reason": "reset_at_end"}
    if b_captured &lt;= a_captured or b_snapshot &lt;= a_snapshot:
        return {"usable": False, "reason": "no_forward_interval"}
    if any((captured - snapshot).total_seconds() &gt; max_age_seconds
           for captured, snapshot in ((a_captured, a_snapshot), (b_captured, b_snapshot))):
        return {"usable": False, "reason": "stale_when_captured"}
    if b_count &lt; a_count:
        return {"usable": False, "reason": "counter_decreased"}
    return {"usable": True, "delta_decimal_GB": format(
        (b_count - a_count) / UNITS["decimal_GB"], "f"),
        "from_snapshot": a_snapshot.isoformat(),
        "to_snapshot": b_snapshot.isoformat()}


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("csv_file")
    parser.add_argument("--max-age-seconds", required=True, type=int)
    args = parser.parse_args()
    with open(args.csv_file, newline="", encoding="utf-8") as stream:
        rows = list(csv.DictReader(stream))
    if len(rows) != 2:
        parser.error("Export exactly two selected readings")
    print(json.dumps(assess_pair(rows[0], rows[1],
                               max_age_seconds=args.max_age_seconds), indent=2))
</code></pre>
<p>Run the fictional sample:</p>
<pre><code class="language-sh">python3 journal.py sample.csv --max-age-seconds 300
</code></pre>
<p>The <code>300</code> is an example policy: each snapshot must have been no more than five minutes old <strong>when captured</strong>. It is not a provider refresh promise, and it does not make an old journal entry current today. Choose and record a suitable policy for your source instead of copying the number without a reason.</p>
<p>A usable result contains <code>delta_decimal_GB</code> and the two source timestamps. An unusable result contains a reason and no delta. The script does not sum different sources, estimate missing traffic, create a daily average or claim the result matches billable usage. Conversion is limited to explicitly named units and at most eighteen input digits; oversized or fractional-byte inputs are rejected rather than rounded into an apparently precise result.</p>
<h2>Interpret a rejected pair</h2>
<ul>
<li><code>reset_at_end</code> or <code>counter_decreased</code>: establish a new baseline. Do not turn the decrease into negative usage or assume a top-up erased earlier consumption.</li>
<li><code>changed_source</code>, <code>changed_line</code> or <code>changed_period</code>: choose a genuinely comparable pair. A provider's cumulative used counter is not interchangeable with a phone's counter.</li>
<li><code>stale_when_captured</code> or <code>no_forward_interval</code>: retain the uncertainty. Reading the same cached snapshot twice does not establish zero additional usage.</li>
<li><code>missing_value</code>, <code>unknown_unit</code> or <code>invalid_time</code>: recover the missing context before calculating. Do not fill it with zero, a guessed unit or an assumed timezone.</li>
</ul>
<p>This is a consistency check on the information you supplied. It cannot detect an unrecorded reset, a mislabelled line or a provider counter whose meaning changed without a new period label. Keep those limits visible with any exported result.</p>
<h2>What was checked</h2>
<p>On 23 October 2026, fifteen offline unittest cases passed with Python 3.12.14. They cover known unit conversion, valid zero, missing values, resets/decreases, changed identities, snapshot-age boundaries, duplicate and reversed times, timezone offsets, invalid counters and fractional bytes. The standalone CSV command was also run, and the complete displayed Python block was compared with the executed file.</p>
<p>These checks validate the local input rules, not device-counter accuracy, plan availability, network performance or the amount a provider will charge.</p>
<p>Once you have a representative comparable sample, use the <a href="https://portproof.org/tools/esim-data-calculator">eSIM data calculator</a> to make your own trip assumptions explicit. If the sample includes laptop sharing, the <a href="https://portproof.org/guides/esim-hotspot">hotspot guide</a> explains how to avoid counting the same work twice. Coverage, validity and activation conditions still need their own plan checks.</p>
]]></content:encoded></item><item><title><![CDATA[HTTPX vs Requests: choose and migrate]]></title><description><![CDATA[Compare HTTPX and Requests for Python jobs. Check redirects, timeouts, environment routing and error handling before migrating a working integration.]]></description><link>https://portproof.hashnode.dev/httpx-vs-requests</link><guid isPermaLink="true">https://portproof.hashnode.dev/httpx-vs-requests</guid><category><![CDATA[Python]]></category><dc:creator><![CDATA[portproofas]]></dc:creator><pubDate>Fri, 23 Oct 2026 00:18:05 GMT</pubDate><content:encoded><![CDATA[<p>Prepared with AI assistance for Portproof. The local checks in this article concern only the stated synthetic fixture; they do not establish live service performance.</p>
<p>Choose a Python HTTP client for the application you need to run, then preserve its request policy. Redirects, timeout phases, environment routing and error handling can change when a working Requests integration moves to HTTPX.</p>
<h2>Short answers</h2>
<h3>Should I use HTTPX or Requests?</h3>
<p>Keep Requests when its synchronous interface fits a maintained application. Consider HTTPX when you need its native async client or optional HTTP/2, then test the workflow before migrating. Neither choice guarantees faster requests or better proxy results.</p>
<h3>Can I replace requests.get with httpx.get?</h3>
<p>Do not assume identical behaviour. Requests GET follows redirects by default; HTTPX does not. Timeout defaults, response-error checks, proxy arguments and exception classes also differ. Make the intended policy explicit.</p>
<h3>Does HTTPX use the same proxies argument as Requests?</h3>
<p>No. Current HTTPX Client uses proxy= for one gateway; Requests uses a proxies dictionary. Complex HTTPX routing uses its supported transport configuration. Configure the client version actually installed.</p>
<h2>Choose for the application you need to maintain</h2>
<p>Requests fits a synchronous job that already works with its API and dependencies. HTTPX is useful when the application needs a native async client, an explicit transport configuration or optional HTTP/2. Migrating just to obtain a different library name adds work without establishing a better result. Decide which interface the application needs, then test the behaviour that matters to that job.</p>
<p>This comparison concerns the documented default clients, rather than every third-party adapter. If the question is simply how to connect through a gateway, use the complete <a href="https://portproof.org/guides/python-requests-proxy">Requests proxy setup</a> or <a href="https://portproof.org/guides/httpx-proxy">HTTPX proxy setup</a>. Their credentials, certificate trust and proxy arguments are deliberately different.</p>
<p><strong>Practical differences to review before choosing a client</strong></p>
<table>
<thead>
<tr>
<th>Decision</th>
<th>Requests</th>
<th>HTTPX</th>
</tr>
</thead>
<tbody><tr>
<td>Synchronous work</td>
<td>Session provides a synchronous interface.</td>
<td>Client provides a synchronous interface; changing libraries does not require async.</td>
</tr>
<tr>
<td>Async application</td>
<td>The default adapter performs blocking I/O. Third-party integrations are outside this comparison.</td>
<td>AsyncClient supports async requests; the calling application still needs bounded concurrency.</td>
</tr>
<tr>
<td>Connection lifetime</td>
<td>Reuse a scoped Session for related requests.</td>
<td>Reuse a scoped Client or AsyncClient for related requests.</td>
</tr>
<tr>
<td>GET redirects by default</td>
<td>Follows redirects. HEAD has a different default.</td>
<td>Does not follow redirects unless enabled.</td>
</tr>
<tr>
<td>Timeout defaults</td>
<td>No timeout unless supplied.</td>
<td>Default network-inactivity protection; configure the phases deliberately.</td>
</tr>
<tr>
<td>One proxy gateway</td>
<td>A proxies dictionary maps destination schemes to gateway URLs.</td>
<td>Current Client accepts proxy=; use supported transports/mounts for more complex routing.</td>
</tr>
<tr>
<td>HTTP/2</td>
<td>Not provided by the default Requests adapter.</td>
<td>Optional dependencies plus http2=True; actual protocol depends on negotiation.</td>
</tr>
</tbody></table>
<p>The <a href="https://www.python-httpx.org/compatibility/">HTTPX compatibility guide</a> documents the API differences. Its native <a href="https://www.python-httpx.org/async/">async interface</a> is a reason to consider a migration, not evidence that a particular workload will run faster. <a href="https://www.python-httpx.org/http2/">HTTP/2 support</a> is opt-in and does not guarantee that every response uses HTTP/2. No throughput benchmark is claimed here.</p>
<h2>Make redirects and accepted responses explicit</h2>
<p>A Requests GET can receive a redirect and return the response from a later request. An HTTPX GET normally returns the redirect itself. A simple rename can therefore change both the visible status and the number of requests. Use <code>allow_redirects=False</code> with Requests or <code>follow_redirects=False</code> with HTTPX when the job must inspect the first response. If redirects are part of the intended workflow, enable them deliberately and review allowed destinations and request limits.</p>
<p>There is another migration trap: Requests <code>raise_for_status()</code> raises for 4xx and 5xx responses; HTTPX raises for responses outside 2xx. In our local 302 check, Requests returned without an exception and HTTPX raised <code>HTTPStatusError</code>. Neither behaviour defines your application’s success. A job expecting one JSON document can require status 200 and specific content before accepting the result. See the <a href="https://requests.readthedocs.io/en/latest/user/quickstart/#response-status-codes">Requests response checks</a> and <a href="https://www.python-httpx.org/quickstart/#response-status-codes">HTTPX status checks</a>.</p>
<p>Keep response errors separate from transport errors. An HTTP 429 is a response requiring the service’s rate-limit policy; a failed connection or tunnel may have no destination response at all. An exception handler copied from Requests will not automatically catch HTTPX’s exception classes. Record the client’s error category without printing a credential-bearing URL or the raw exception. Use <a href="https://portproof.org/guides/429-too-many-requests">429 troubleshooting</a> for retry decisions.</p>
<h2>Transfer the timeout policy rather than a number</h2>
<p>Requests accepts a connection/read tuple, such as <code>(2, 3)</code>. HTTPX separates connect, read, write and connection-pool waits, so <code>httpx.Timeout(3, connect=2, pool=1)</code> gives those phases different example limits. These numbers demonstrate configuration; they are not recommended limits for every network. Select budgets from your authorised job’s requirements.</p>
<p>A read limit is a wait for incoming data, not a deadline for the entire download. A response that keeps supplying chunks can last longer. Give the worker a separate overall deadline and cap its queue, attempts and response size. HTTPX’s <a href="https://www.python-httpx.org/advanced/timeouts/">timeout reference</a> explains the phases; Requests documents its <a href="https://requests.readthedocs.io/en/latest/user/quickstart/#timeouts">timeout boundaries</a>. Changing clients does not create an overall job deadline.</p>
<h2>Preserve the intended network and trust policy</h2>
<p>Both clients can read environment proxy configuration. A worker shell, notebook and deployed service may therefore use different routes. Make the policy explicit before comparing results. <code>trust_env=False</code> disables environment-derived behaviour; it also affects certificate or authentication configuration, depending on the client. Do not use it to bypass an organisation’s required proxy or approved CA policy.</p>
<p>The <a href="https://www.python-httpx.org/environment_variables/">HTTPX environment reference</a> covers its proxy and certificate variables. Requests documents environment proxies in <a href="https://requests.readthedocs.io/en/latest/user/advanced/#proxies">advanced usage</a> and its <a href="https://requests.readthedocs.io/en/latest/api/#requests.Session.trust_env">Session trust setting</a>. The existing Portproof setup guides supply complete explicit gateway and trust settings. Keep TLS verification enabled, and keep gateway credentials separate from destination login.</p>
<p>A Python Session or Client owns connections and cookies; it is not the gateway’s sticky-session setting. Reusing a connection can preserve a tunnel, so a new function call does not guarantee a new exit address. Preserve the generated gateway username for the intended workflow and consult <a href="https://portproof.org/guides/rotating-vs-sticky-proxy-sessions">rotation and sticky sessions</a>. Dedicated-device rental and shared traffic billed by GB also remain separate products.</p>
<h2>Use the same small response contract in both clients</h2>
<p>The following helpers expect a synthetic JSON document with <code>ready</code> equal to <code>true</code>. Both reject a redirect or any status other than 200 before reading the body. They cap collected decoded content at 64 KiB, disable redirects, add no retry, and close the response and client on success or failure. The output reports status and a category; it never prints the URL or a response body.</p>
<p><strong>Install the versions used in the local fixture</strong></p>
<pre><code class="language-sh">python -m pip install "requests==2.34.2" "httpx==0.28.1"
</code></pre>
<p><strong>comparison_check.py — one controlled read with either client</strong></p>
<pre><code class="language-python">import json

import httpx
import requests


def read_result(response, chunks):
    status = response.status_code
    if status != 200:
        return {"status": status, "accepted": False, "reason": "status"}
    body = bytearray()
    for chunk in chunks:
        if len(body) + len(chunk) &gt; 65536:
            raise ValueError("Response exceeds 64 KiB")
        body.extend(chunk)
    payload = json.loads(body)
    accepted = isinstance(payload, dict) and payload.get("ready") is True
    return {"status": status, "accepted": accepted,
            "reason": "accepted" if accepted else "content"}


def requests_check(url):
    status = None
    try:
        with requests.Session() as client:
            client.trust_env = False
            with client.get(url, timeout=(2, 3), allow_redirects=False,
                            stream=True, verify=True) as response:
                status = response.status_code
                return read_result(response, response.iter_content(8192))
    except requests.RequestException as error:
        return {"status": status, "accepted": False,
                "reason": type(error).__name__}
    except ValueError:
        return {"status": status, "accepted": False, "reason": "ValueError"}


def httpx_check(url):
    status = None
    try:
        with httpx.Client(trust_env=False, follow_redirects=False,
                          verify=True,
                          timeout=httpx.Timeout(3, connect=2, pool=1)) as client:
            with client.stream("GET", url) as response:
                status = response.status_code
                return read_result(response, response.iter_bytes(8192))
    except httpx.HTTPError as error:
        return {"status": status, "accepted": False,
                "reason": type(error).__name__}
    except ValueError:
        return {"status": status, "accepted": False, "reason": "ValueError"}
</code></pre>
<p>For a local fixture URL, call either <code>requests_check(url)</code> or <code>httpx_check(url)</code>. These are isolated direct-connection examples with environment settings disabled. They do not configure a Portproof gateway or replace the complete setup guides. The Requests tuple limits connection/read waits; HTTPX also limits write/pool waits. The decoded-content cap does not measure billable proxy traffic or impose a whole-job deadline.</p>
<p>Each helper opens a client for one diagnostic read. In an application processing related requests, move that client’s lifetime outside the loop and preserve response cleanup. Do not create an AsyncClient inside every iteration of a hot loop. Start with one authorised task before adding concurrency or implementing a retry policy.</p>
<h2>What we checked locally</h2>
<p>On 23 October 2026, we ran the exact displayed Python helpers with Python 3.12.14, Requests 2.34.2 and HTTPX 0.28.1 against a synthetic loopback HTTP origin. No live proxy, real credential or external destination was used. Separate checks observed the clients’ default GET redirect behaviour and their handling of an unfollowed 302.</p>
<p><strong>Observed results from the isolated client-behaviour fixture</strong></p>
<table>
<thead>
<tr>
<th>Case</th>
<th>Observed result</th>
</tr>
</thead>
<tbody><tr>
<td>Default Requests GET to one local redirect</td>
<td>Followed the 302 to the local 200 endpoint; two origin requests.</td>
</tr>
<tr>
<td>Default HTTPX GET to that redirect</td>
<td>Returned 302; one origin request.</td>
</tr>
<tr>
<td>Calling raise_for_status on an unfollowed 302</td>
<td>Requests did not raise; HTTPX raised HTTPStatusError.</td>
</tr>
<tr>
<td>Displayed helpers: valid JSON, redirect and 429</td>
<td>Accepted the required 200 JSON; rejected 302 and 429 with one origin request each and no retry.</td>
</tr>
<tr>
<td>Displayed helpers: invalid JSON, wrong content and oversized body</td>
<td>Rejected all three; HTTP 200 alone was insufficient.</td>
</tr>
<tr>
<td>Displayed helpers: headers or body stalled</td>
<td>Before headers, both reported ReadTimeout. With headers received and the body stalled, Requests reported ConnectionError and HTTPX ReadTimeout in this streamed-response fixture.</td>
</tr>
<tr>
<td>Conflicting synthetic proxy, CA and netrc environment</td>
<td>The explicitly isolated helpers still reached the local origin; no environment-selected gateway was used.</td>
</tr>
</tbody></table>
<blockquote>
<p>These checks establish the named clients’ local HTTP behaviour. They do not test TLS, authenticated CONNECT, async cancellation, live proxy availability, exit geography, throughput or customer conversion. The setup guides record their own separate proxy/TLS checks.</p>
</blockquote>
<h2>Migrate one observable workflow before scaling</h2>
<ol>
<li>Define the accepted response: status, content type or body shape, and any required application field. Keep rejected tasks in the record.</li>
<li>Copy the intended redirect, proxy, trust, timeout and cookie policies into the new client’s supported interface. Check the installed version and remove obsolete arguments.</li>
<li>Review existing exception handlers and retries. Do not multiply an adapter retry policy by an outer loop or automatically replay an uncertain purchase or form submission.</li>
<li>Run a small controlled read and compare request count, response acceptance and cleanup. A successful echo or parsed JSON alone does not establish every later application request.</li>
<li>Measure one representative permitted workflow, then use the <a href="https://portproof.org/guides/how-many-gb-of-proxy-traffic-do-i-need">traffic planning guide</a> and <a href="https://portproof.org/pricing">current pricing</a> for shared traffic sold by GB. A library migration alone provides no bandwidth saving or performance guarantee.</li>
</ol>
<p>Use these clients for authorised QA, monitoring and research that respects the destination’s terms and access rules. A working connection does not grant permission to collect information or send requests. See the <a href="https://portproof.org/legal/aup">acceptable-use policy</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Wget proxy setup with authentication]]></title><description><![CDATA[Set an authenticated proxy in GNU Wget 1, protect the password, verify HTTPS certificates and bound downloads with a tested local example.]]></description><link>https://portproof.hashnode.dev/wget-proxy</link><guid isPermaLink="true">https://portproof.hashnode.dev/wget-proxy</guid><category><![CDATA[Linux]]></category><dc:creator><![CDATA[portproofas]]></dc:creator><pubDate>Thu, 22 Oct 2026 23:30:59 GMT</pubDate><content:encoded><![CDATA[<p>Prepared with AI assistance for Portproof. The historical local checks remain dated 1 October 2026; this republication does not claim a new live gateway test or performance result.</p>
<p>Configure GNU Wget 1 to use an authenticated HTTP proxy, check an HTTPS destination and keep the password out of command history. Start with one bounded echo request before downloading a permitted dataset.</p>
<h2>Short answers</h2>
<h3>How do I set a proxy in Wget?</h3>
<p>GNU Wget 1 reads http_proxy and https_proxy settings from its environment or configuration file. The example below supplies both in a separate configuration file and removes conflicting inherited proxy settings.</p>
<h3>Which password option authenticates the proxy?</h3>
<p>Use proxy_user and proxy_password for the gateway. Website credentials and --ask-password serve a different purpose; they do not replace these proxy settings.</p>
<h3>Can an HTTP proxy carry an HTTPS download?</h3>
<p>Yes. Wget requests a CONNECT tunnel through the HTTP gateway, then verifies the destination certificate. The initial proxy authentication is not encrypted by that destination TLS connection.</p>
<h2>Which Wget proxy setting should you change?</h2>
<p>Start with the URL you are downloading, then the gateway it should use. An HTTPS download needs <code>https_proxy</code>; setting only <code>http_proxy</code> does not configure that destination scheme. The gateway value can still begin with <code>http://</code>. GNU’s <a href="https://www.gnu.org/software/wget/manual/html_node/Proxies.html">proxy reference</a> documents these lower-case variables and their configuration-file equivalents.</p>
<p><strong>Choose the route</strong> — Use <code>http_proxy</code> for an HTTP destination and <code>https_proxy</code> for an HTTPS destination. The example sets both explicitly in its temporary configuration. Copy the HTTP gateway from your connection builder; the destination’s <code>https://</code> prefix does not turn a SOCKS listener into an HTTP proxy.</p>
<p><strong>Authenticate the gateway</strong> — Use <code>proxy_user</code> and <code>proxy_password</code>. Keep the complete generated proxy username, including its pool, country and session choices. Website authentication is separate: changing a destination login does not repair a rejected proxy login.</p>
<p><strong>Check exclusions and inherited settings</strong> — Inspect the job’s own routing configuration. <code>no_proxy</code> can exclude a destination; startup-file settings can override environment values. A scheduled task can have a different environment from your terminal. Do not paste an environment dump containing secrets into a support ticket.</p>
<p>Use the <a href="https://portproof.org/guides/wget-proxy#example">bounded example below</a> when you need a controlled authentication check. It avoids putting a password in a URL or command argument. If that succeeds but your normal Wget job fails, compare the job’s destination scheme, configuration source and exclusions before changing the credential. A successful echo is evidence for that request only.</p>
<h2>Check that you are running GNU Wget 1</h2>
<p>This guide uses GNU Wget 1.25.0 and a Python wrapper on macOS or Linux. Wget 2 is a separate implementation; BusyBox and shell aliases can also behave differently. Check the actual executable before adopting flags from a tutorial. The wrapper finds <code>wget</code> on PATH and checks its version output.</p>
<p><strong>Check the installed executable</strong></p>
<pre><code class="language-sh">wget --no-config --version
</code></pre>
<p>Install GNU Wget through your operating system’s trusted package source if needed. The wrapper requires Python 3.10 or newer and uses only its standard library. We tested with Python 3.12.14 and an isolated Wget build using OpenSSL. Windows users need a suitable Unix environment for this pipe-monitoring example.</p>
<h2>Separate the gateway URL from the destination</h2>
<p>Copy the full username from the <a href="https://portproof.org/app">connection builder</a>, choosing an available pool and country. Use the proxy password supplied for that connection. The website account password and developer API key are different credentials. The hostname and port in this page’s example come from the site’s gateway configuration.</p>
<p>For an HTTPS destination, <code>https_proxy</code> can still contain an <code>http://</code> gateway URL: the setting selects traffic by destination scheme. Wget then uses CONNECT. An HTTP destination uses <code>http_proxy</code> without destination TLS. Both settings here point to the same authenticated HTTP gateway. For SOCKS configuration, follow the separate <a href="https://portproof.org/guides/test-a-proxy-with-curl#socks5">curl guide</a>.</p>
<h2>Run one bounded echo check</h2>
<p>Save the following as <code>wget_check.py</code>, then run <code>python3 wget_check.py</code> in an interactive terminal. Paste the complete username at the first prompt and enter the password at the hidden prompt. An existing <code>PROXY_USER</code> environment variable supplies the username instead. Neither prompt writes the password into a shell command.</p>
<p><strong>wget_check.py</strong></p>
<pre><code class="language-python">import getpass
import ipaddress
import json
import os
from pathlib import Path
import selectors
import shutil
import subprocess
import tempfile
import time
import warnings
from urllib.parse import urlsplit


class CheckFailure(Exception):
    pass


def check():
    wget = shutil.which("wget")
    if not wget:
        raise CheckFailure("Install GNU Wget 1 and check your PATH")
    version = subprocess.run([wget, "--no-config", "--version"],
                             capture_output=True, text=True, timeout=5)
    if not version.stdout.startswith("GNU Wget 1."):
        raise CheckFailure("This example requires GNU Wget 1")
    server = os.environ.get("PROXY_SERVER", "http://gw.portproof.org:7000")
    parsed = urlsplit(server)
    if (parsed.scheme != "http" or not parsed.hostname or not parsed.port
            or parsed.username is not None or parsed.password is not None
            or parsed.path not in ("", "/") or parsed.query or parsed.fragment
            or any(ord(c) &lt;= 32 or ord(c) == 127 for c in server)):
        raise CheckFailure("Use an HTTP gateway URL without credentials")
    user = os.environ.get("PROXY_USER") or input("Full proxy username: ")
    warnings.simplefilter("error", getpass.GetPassWarning)
    password = getpass.getpass("Proxy password: ")
    for value in (user, password):
        if (not value or value != value.strip()
                or any(ord(c) &lt; 32 or ord(c) == 127 for c in value)):
            raise CheckFailure("This config method requires nonempty credentials without edge whitespace or controls")
    if ":" in user:
        raise CheckFailure("A Basic-auth username cannot contain a colon")
    env = {k: v for k, v in os.environ.items()
           if k.lower() not in {"http_proxy", "https_proxy", "ftp_proxy",
                                "all_proxy", "no_proxy", "wgetrc"}}
    with tempfile.TemporaryDirectory(prefix="wget-check-") as folder:
        config = Path(folder) / "wgetrc"
        fd = os.open(config, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
        with os.fdopen(fd, "w", encoding="utf-8") as stream:
            stream.write(f"use_proxy = on\nhttp_proxy = {server}\nhttps_proxy = {server}\n"
                         f"no_proxy =\nproxy_user = {user}\nproxy_password = {password}\n")
        command = [wget, f"--config={config}", "--quiet", "--no-netrc",
                   "--no-hsts", "--no-cookies", "--tries=1", "--timeout=5",
                   "--max-redirect=0", "--output-document=-"]
        if os.environ.get("PROXY_CA_BUNDLE"):
            command.append("--ca-certificate=" + os.environ["PROXY_CA_BUNDLE"])
        command.append("https://api.portproof.org/v1/echo-ip")
        with subprocess.Popen(command, stdout=subprocess.PIPE,
                              stderr=subprocess.DEVNULL, env=env) as process:
            deadline = time.monotonic() + 20
            data = bytearray()
            try:
                with selectors.DefaultSelector() as ready:
                    ready.register(process.stdout, selectors.EVENT_READ)
                    while True:
                        left = deadline - time.monotonic()
                        if left &lt;= 0 or not ready.select(left):
                            raise CheckFailure("Overall download deadline reached")
                        chunk = os.read(process.stdout.fileno(), 4096)
                        if not chunk:
                            break
                        data.extend(chunk)
                        if len(data) &gt; 65536:
                            raise CheckFailure("Echo body exceeds 64 KiB")
                process.wait(timeout=max(0.001, deadline - time.monotonic()))
                if process.returncode:
                    raise CheckFailure(f"Wget exited with status {process.returncode}")
            finally:
                if process.poll() is None:
                    process.kill()
                process.wait()
        payload = json.loads(data)
        if not isinstance(payload, dict) or not isinstance(payload.get("ip"), str):
            raise ValueError("Echo response has no IP string")
        address = ipaddress.ip_address(payload["ip"])
        print("Exit address:", address)


if __name__ == "__main__":
    try:
        check()
    except CheckFailure as error:
        print(error)
        raise SystemExit(1)
    except (OSError, ValueError, subprocess.SubprocessError, getpass.GetPassWarning) as error:
        print("Check failed:", type(error).__name__)
        raise SystemExit(1)
</code></pre>
<p>The wrapper prints a validated exit address only after Wget succeeds and the response contains an IP string. That address describes this request; it does not establish country accuracy, future availability or access to another site. Next, test one permitted operation from the application that will perform the real work.</p>
<h2>Use a temporary file for proxy credentials</h2>
<p>The password goes into a mode-0600 file inside a private temporary directory, rather than the gateway URL or process arguments. It remains plaintext in that protected file while Wget runs. Handled failures and timeouts remove the directory; forced termination can leave it behind, so use a trusted account and temporary storage.</p>
<p>This method passes raw punctuation directly to Wget’s configuration parser. Do not URL-encode the password or add quotation marks around it. The parser trims surrounding whitespace, so the wrapper rejects leading or trailing whitespace and control characters instead of silently changing credentials. If those characters are required, use a client with a separate authentication object, such as <a href="https://portproof.org/guides/httpx-proxy">HTTPX</a>.</p>
<p>The script rejects a colon in a Basic-auth username. It also stops if hidden password entry is unavailable. For unattended work, replace the prompts with your established secret-manager lookup and retain the same validation and file protections. Keep generated configuration files out of repositories, archives and diagnostic attachments.</p>
<h2>Control inherited proxy and configuration settings</h2>
<p>GNU’s <a href="https://www.gnu.org/software/wget/manual/html_node/Proxies.html">proxy documentation</a> describes the lower-case environment variables and configuration settings. A job scheduler or service may inherit different values from your terminal. The wrapper clears inherited proxy variables for its child process, uses a dedicated configuration file and disables netrc credentials, cookies and HSTS persistence for this check.</p>
<p>An empty <code>no_proxy</code> setting prevents an exclusion from changing this diagnostic’s route. The local fixture also supplied conflicting environment proxies and WGETRC settings; the chosen gateway still received the request. Apply explicit routing only where it fits your network policy. The <a href="https://portproof.org/guides/proxy-works-in-curl-but-not-in-your-app">application troubleshooting guide</a> explains how to compare processes.</p>
<h2>Bound attempts, elapsed time and response data</h2>
<p>Wget receives one URL, one permitted attempt and no redirects. Its five-second timeout limits network waiting; it is not an overall runtime limit. The wrapper separately stops the download after twenty seconds, including a response that keeps delivering small chunks. Password entry and the preliminary version check happen before that download deadline.</p>
<p>The wrapper also stops when the echo body exceeds 64 KiB. That bounds the diagnostic’s buffered response, not billable traffic: headers, buffering and protocol overhead remain separate. For larger files, choose deliberate download limits and measure account usage. Wget’s quota option is not a reliable single-file cutoff; see its <a href="https://www.gnu.org/software/wget/manual/html_node/Download-Options.html">download options</a>.</p>
<h2>Read failures by connection stage</h2>
<p>A process exit code is not an HTTP response status. In particular, Wget status <code>4</code> does not distinguish a failed connection from every rejected tunnel. Keep the exact redacted error and use the <a href="https://portproof.org/guides/connect-tunnel-failed#prove-it">CONNECT diagnostic</a> to record the tunnel status separately. GNU’s <a href="https://www.gnu.org/software/wget/manual/html_node/Exit-Status.html">exit-status reference</a> defines the broader categories; the observations below are from the named local fixture.</p>
<p><strong>Wget status 4</strong> — In our HTTPS fixture, rejected CONNECT requests with 407 or 503 and stalled downloads returned this network-error status. It does not identify authentication failure by itself. Check the gateway, generated username and proxy password, then use the <a href="https://portproof.org/guides/407-proxy-authentication-required">407 diagnostic</a>. If the gateway is not reached at all, the <a href="https://portproof.org/guides/proxy-connection-refused">connection-refused guide</a> isolates that stage with curl.</p>
<p><strong>Wget status 5</strong> — Untrusted and mismatched destination certificates returned this status locally. Correct the hostname or trust chain. An approved PROXY_CA_BUNDLE supplies a CA file to this example; certificate verification stays enabled.</p>
<p><strong>Wget status 8</strong> — Destination 403, 407 and 429 responses inside an established HTTPS tunnel returned this status, as did a refused redirect. Respect access restrictions and retry guidance. A destination 407 is distinct from a gateway rejecting CONNECT.</p>
<h2>What the local tests established</h2>
<p>On 1 October 2026, the example ran against local HTTP and HTTPS origins through an authenticated fixture proxy. Checks covered punctuation, routing overrides, rejected tunnels, certificate rejection, destination errors, redirects, malformed echo responses, oversized bodies and timeouts. The HTTPS origin received no proxy credentials, and handled exits removed the private configuration and closed the connection.</p>
<p>These checks establish the named client behaviour, without measuring live pool performance. Wget does not rotate addresses itself: choose gateway rotation or a sticky session in the username, as explained in the <a href="https://portproof.org/guides/rotating-vs-sticky-proxy-sessions">session guide</a>. Before scaling, define permitted destinations, finite retries and a <a href="https://portproof.org/guides/proxies-for-web-scraping">measured traffic budget</a>.</p>
<p>Use Wget for authorised research, monitoring and QA that follows destination rules and the <a href="https://portproof.org/legal/aup">acceptable-use policy</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Before you scale a price checker]]></title><description><![CDATA[Use fictional offers to compare delivered prices, reject mismatches and estimate proxy traffic from a complete, permitted workload sample.]]></description><link>https://portproof.hashnode.dev/before-you-scale-a-price-checker</link><guid isPermaLink="true">https://portproof.hashnode.dev/before-you-scale-a-price-checker</guid><category><![CDATA[Testing]]></category><dc:creator><![CDATA[portproofas]]></dc:creator><pubDate>Wed, 14 Oct 2026 13:15:55 GMT</pubDate><content:encoded><![CDATA[<p>Before scaling a price checker, define what counts as a valid observation. A successful HTTP request does not establish that you received the right product, currency or delivery conditions.</p>
<p>In our fictional example, the item price rises from EUR 24 to EUR 25. Delivery falls from EUR 4 to an explicitly stated EUR 0, so the delivered total falls from EUR 28 to EUR 25. Keep the product, variant, quantity, seller, delivery destination and tax treatment comparable. Missing delivery is unknown; it is not free.</p>
<p>A second fictional observation returns USD when the acceptance rule requires EUR. Keep that attempt with its rejection reason, but do not store it as a new comparable price. Its traffic still belongs in your measured sample.</p>
<h2>Estimate from the whole sample</h2>
<p>The walkthrough uses a separate, invented workload sample: 10,800,000 transferred bytes and 120 accepted observations. These are teaching inputs, not results from the three offers above or from a retailer test.</p>
<p>That gives 90,000 bytes per accepted observation. A schedule of 1,000 products across two markets, checked four times daily for 30 days, contains 240,000 planned observations:</p>
<ul>
<li>240,000 × 90,000 bytes = 21.6 GB.</li>
<li>Adding a chosen 20% allowance gives 25.92 GB.</li>
</ul>
<p>Here, 1 GB means 1,000,000,000 bytes. Failed attempts and retries belong in the sample total once. The allowance is a planning choice, not a guarantee or a bill. Measure a permitted sample with your intended collection method and reconcile your counter with settled provider usage before buying for a larger run.</p>
<p><a href="https://portproof.org/demos/price-monitoring/index.html">Explore the four-scene walkthrough</a>.</p>
<p><a href="https://portproof.org/tools/price-monitoring-calculator?utm_source=hashnode&amp;utm_medium=referral&amp;utm_campaign=price_observation_20261014">Open the traffic planner with your own measurements</a>.</p>
<p>For the complete local Python offer-validation fixture, see our existing article, <a href="https://portproof.hashnode.dev/competitor-price-monitoring-with-python">Competitor price monitoring with Python</a>.</p>
<p>Disclosure: Prepared for Portproof, which sells shared mobile and residential proxy traffic by the GB. Your software handles collection, product matching and alerts. All prices and workload inputs in this walkthrough are fictional; no retailer or proxy service was tested. AI assisted the preparation and technical review.</p>
]]></content:encoded></item><item><title><![CDATA[Competitor price monitoring with Python]]></title><description><![CDATA[Validate comparable offers before recording prices. A complete Python fixture tests wrong products, missing amounts, stock and HTTP failures.]]></description><link>https://portproof.hashnode.dev/competitor-price-monitoring-with-python</link><guid isPermaLink="true">https://portproof.hashnode.dev/competitor-price-monitoring-with-python</guid><category><![CDATA[Python]]></category><category><![CDATA[Testing]]></category><dc:creator><![CDATA[portproofas]]></dc:creator><pubDate>Thu, 08 Oct 2026 13:31:01 GMT</pubDate><content:encoded><![CDATA[<p>Disclosure: This article was prepared for Portproof, which sells shared residential and mobile proxy traffic by the GB. The worked example uses fictional offers served locally; no retailer or proxy service was tested. AI assisted the drafting and technical review. Recorded fixture results are dated below; they do not represent a live retailer test.</p>
<p>A price monitor can fetch a page successfully and still record the wrong price. It may have received a different pack size, another seller's offer, an unavailable item or a cookie-preference page. <a href="https://www.rfc-editor.org/rfc/rfc9110.html#name-200-ok">HTTP 200 describes successful HTTP processing</a>; your collector must decide whether the response contains a comparable offer.</p>
<p>Start with that decision before choosing a browser, proxy route or collection schedule.</p>
<h2>Define what counts as the same offer</h2>
<p>For each monitored product, specify the SKU, variant, pack quantity, seller, currency, delivery destination and tax treatment. Decide how promotions and membership conditions affect eligibility. A headline price for a smaller pack or a members-only discount should not silently replace an ordinary offer.</p>
<p>In this example, an accepted offer must describe fictional SKU <code>EXAMPLE-500</code>, orange variant, a 500 ml pack, seller <code>fictional-test-shop</code>, EUR currency, destination <code>TEST-POSTCODE</code>, tax included and <code>InStock</code> availability. Both item price and shipping must be present. The comparable value is their sum.</p>
<p>This is a deliberately small normalised JSON format. It does not extract prices from arbitrary retail HTML. A real implementation still needs permission to use its source, a parser for that source and explicit rules for its offer conditions.</p>
<h2>Test plausible failures locally</h2>
<p>Copy the complete local fixture at the end of this article into <code>local_price_observation_fixture.py</code>. It runs with Python 3.9 or later and only the standard library:</p>
<pre><code class="language-sh">python3 local_price_observation_fixture.py
</code></pre>
<p>It opens a temporary HTTP listener on <code>127.0.0.1</code>, makes one request per case and closes the listener. Inherited proxy settings are explicitly disabled. It uses no browser, external shop, account or credential, and performs no retries.</p>
<p>The recorded run on 5 October 2026 produced:</p>
<table>
<thead>
<tr>
<th>Response group</th>
<th>Responses</th>
<th>Accepted offers</th>
</tr>
</thead>
<tbody><tr>
<td>Correct fictional offers</td>
<td>2</td>
<td>2</td>
</tr>
<tr>
<td>Wrong SKU, variant or currency</td>
<td>3</td>
<td>0</td>
</tr>
<tr>
<td>Missing item price, shipping or stock</td>
<td>3</td>
<td>0</td>
</tr>
<tr>
<td>Cookie-preference HTML, with HTTP 200</td>
<td>1</td>
<td>0</td>
</tr>
<tr>
<td>HTTP 429 and HTTP 403</td>
<td>2</td>
<td>0</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td><strong>11</strong></td>
<td><strong>2</strong></td>
</tr>
</tbody></table>
<p>Nine responses return HTTP 200. Only two satisfy the offer requirements. These cases were selected to exercise failures; their proportion is not a real-shop success rate.</p>
<h2>Treat missing amounts as missing</h2>
<p>The validator checks status, content type, document shape, offer identity and stock before calculating a total. For this EUR example, amounts must be non-negative strings with exactly two fractional digits and at most ten whole-number digits. Other currencies require their own rules.</p>
<p>This excerpt is the fixture's amount parser, not the complete validator:</p>
<pre><code class="language-python">import re
from decimal import Decimal

def amount(value):
    if not isinstance(value, str) or not re.fullmatch(
        r"(?:0|[1-9][0-9]{0,9})\.[0-9]{2}", value
    ):
        raise ValueError("missing-or-invalid-amount")
    result = Decimal(value)
    if not result.is_finite() or result &lt; 0:
        raise ValueError("missing-or-invalid-amount")
    return result
</code></pre>
<p>An explicit shipping value of <code>"0.00"</code> is accepted. Missing shipping is rejected rather than treated as free delivery. Boolean values, negative amounts and non-finite or exponent-formatted values are rejected. <a href="https://docs.python.org/3/library/decimal.html">Python's decimal arithmetic</a> preserves decimal values without binary floating-point rounding in this calculation.</p>
<p>That distinction changes the result:</p>
<table>
<thead>
<tr>
<th>Observation</th>
<th>Item</th>
<th>Shipping</th>
<th>Delivered total</th>
</tr>
</thead>
<tbody><tr>
<td>First accepted offer</td>
<td>€24.00</td>
<td>€4.00</td>
<td>€28.00</td>
</tr>
<tr>
<td>Next accepted offer</td>
<td>€25.00</td>
<td>€0.00</td>
<td>€25.00</td>
</tr>
</tbody></table>
<p>The item price rose by €1, while the comparable delivered total fell by €3. Keep the components as well as the total so an alert can explain why it changed.</p>
<h2>Use a browser when the source requires it</h2>
<p>An authorised feed or HTTP response may already contain the necessary fields. If a permitted source renders them through JavaScript, use a browser and validate its extracted data with the same acceptance rules. The <a href="https://portproof.org/guides/puppeteer-proxy">Puppeteer proxy setup guide</a> covers routing and a bounded connection check separately.</p>
<p><code>domcontentloaded</code> does not establish that a delayed offer is ready. Wait for the required source-specific state with a time limit, then check identity and values. Preserve cookies and market settings deliberately. A browser context, a provider's sticky session and an HTTP connection are different things; changing a route does not reset storefront cookies or guarantee a new connection for every request.</p>
<p>Keep proxy credentials restricted to recognised proxy authentication challenges. Do not let an arbitrary shop's authentication challenge receive them. The Python measurement above exercises none of this browser behaviour.</p>
<h2>Budget for accepted observations</h2>
<p>The fixture reads 1,718 response-body bytes across all eleven cases. Dividing by two accepted offers gives 859 body bytes per accepted offer. Counting only the two valid responses would discard the cost of rejected attempts.</p>
<p>Those local body bytes exclude headers, TLS, browser subresources and provider metering. They are not a usable real-shop bandwidth forecast. Run a separate permitted sample using the intended collection method, count failed and retried traffic too, and reconcile the counter with the provider's usage meter. Put that sample into the <a href="https://portproof.org/tools/price-monitoring-calculator">price-monitoring traffic calculator</a>, keeping different markets or workflows separate when their costs differ.</p>
<p>Bound simultaneous tasks, response sizes and request durations. Record HTTP 429 instructions; the fixture's <code>Retry-After: 60</code> is recorded without retrying. <a href="https://www.rfc-editor.org/rfc/rfc6585.html#section-4">The 429 specification</a> describes rate limiting and possible retry instructions. Stop refused requests and investigate permissions rather than changing routes to repeat them.</p>
<p>Choose a route after establishing source permission, market requirements and session lifetime. The <a href="https://portproof.org/use-cases/price-monitoring">price-monitoring use case</a> explains HTTP versus browser workflows and shared mobile versus residential pools. A route choice cannot certify the resulting currency, delivery context or offer.</p>
<p>Store the source URL, collection time, extraction version and comparison context with every accepted observation. Keep rejected attempts with a reason and no invented price. Then alerts and cost estimates can use the same definition of a valid observation.</p>
<h2>Complete local fixture</h2>
<p>Save this entire block as <code>local_price_observation_fixture.py</code>, then run the command above. The script needs permission to open a temporary listener on <code>127.0.0.1</code>. It makes no external shop requests, requires no proxy credentials, and disables inherited proxy settings for its local requests.</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""Synthetic local observations: no live proxy, shop, credentials or retry."""
import copy
import json
import re
import threading
from decimal import Decimal, InvalidOperation
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.error import HTTPError
from urllib.request import ProxyHandler, build_opener

BASE = {"sku": "EXAMPLE-500", "variant": "orange", "pack_ml": 500,
        "currency": "EUR", "seller": "fictional-test-shop",
        "destination": "TEST-POSTCODE", "tax": "included",
        "availability": "InStock", "price": "24.00", "shipping": "4.00"}
CASES = []
def case(name, changes, status=200):
    offer = copy.deepcopy(BASE)
    offer.update(changes)
    CASES.append((name, status, json.dumps(offer, separators=(",", ":")).encode()))
case("valid-first", {})
case("valid-next", {"price": "25.00", "shipping": "0.00"})
case("wrong-sku", {"sku": "EXAMPLE-999"})
case("wrong-variant", {"variant": "apple"})
case("wrong-currency", {"currency": "USD"})
case("missing-price", {"price": None})
case("missing-shipping", {"shipping": None})
case("missing-stock", {"availability": None})
CASES.append(("consent-page", 200, b"&lt;html&gt;Choose your cookie preferences&lt;/html&gt;"))
CASES.append(("rate-limit", 429, b"slow down"))
CASES.append(("refusal", 403, b"not permitted"))

class Fixture(BaseHTTPRequestHandler):
    def do_GET(self):
        name = self.path.removeprefix("/")
        chosen = next((row for row in CASES if row[0] == name), None)
        if chosen is None:
            self.send_error(404)
            return
        _, status, body = chosen
        self.send_response(status)
        self.send_header("Content-Type", "text/html" if name == "consent-page" else "application/json")
        self.send_header("Content-Length", str(len(body)))
        if status == 429:
            self.send_header("Retry-After", "60")
        self.end_headers()
        self.wfile.write(body)
    def log_message(self, *args):
        pass

def amount(value):
    if not isinstance(value, str) or not re.fullmatch(r"(?:0|[1-9][0-9]{0,9})\.[0-9]{2}", value):
        raise ValueError("missing-or-invalid-amount")
    result = Decimal(value)
    if not result.is_finite() or result &lt; 0:
        raise ValueError("missing-or-invalid-amount")
    return result

def validate(status, content_type, body):
    if status != 200:
        return None, "http-" + str(status)
    if content_type != "application/json":
        return None, "unexpected-content-type"
    try:
        row = json.loads(body)
        if not isinstance(row, dict):
            return None, "unexpected-document"
        for key in ("sku", "variant", "pack_ml", "currency", "seller", "destination", "tax"):
            if row.get(key) != BASE[key]:
                return None, "mismatch-" + key
        if row.get("availability") != "InStock":
            return None, "missing-or-unavailable-stock"
        total = amount(row.get("price")) + amount(row.get("shipping"))
        return str(total), None
    except (ValueError, InvalidOperation, TypeError):
        return None, "missing-or-invalid-amount"

def main():
    rows = []
    # ProxyHandler({}) avoids inherited proxy settings for this loopback-only fixture.
    opener = build_opener(ProxyHandler({}))
    with HTTPServer(("127.0.0.1", 0), Fixture) as server:
        worker = threading.Thread(target=server.serve_forever, daemon=True)
        worker.start()
        try:
            for name, _, _ in CASES:
                target = "http://127.0.0.1:" + str(server.server_port) + "/" + name
                try:
                    response = opener.open(target, timeout=2)
                except HTTPError as error:
                    response = error
                with response:
                    body = response.read(4097)
                    if len(body) &gt; 4096:
                        raise RuntimeError("fixture body exceeded bound")
                    status = response.status
                    total, reason = validate(status, response.headers.get_content_type(), body)
                    rows.append({"case": name, "status": status, "body_bytes": len(body),
                                 "valid": reason is None, "delivered_total": total, "reason": reason,
                                 "retry_after": response.headers.get("Retry-After")})
        finally:
            server.shutdown()
            worker.join(timeout=3)
    valid = sum(row["valid"] for row in rows)
    body_bytes = sum(row["body_bytes"] for row in rows)
    summary = {"scope": "synthetic loopback fixture; response body bytes only, not billed traffic",
               "requests": len(rows), "http_200": sum(row["status"] == 200 for row in rows),
               "valid_observations": valid, "response_body_bytes": body_bytes,
               "body_bytes_per_valid_observation": body_bytes / valid,
               "rows": rows}
    assert (summary["requests"], summary["http_200"], valid) == (11, 9, 2)
    assert [row["delivered_total"] for row in rows[:2]] == ["28.00", "25.00"]
    assert rows[9]["retry_after"] == "60"
    print(json.dumps(summary, indent=2))

if __name__ == "__main__":
    main()
</code></pre>
]]></content:encoded></item><item><title><![CDATA[Measure a proxy download without hiding errors]]></title><description><![CDATA[Run one bounded HTTPS download through an HTTP proxy, keep credentials out of command arguments, and distinguish tunnel, TLS and destination failures.]]></description><link>https://portproof.hashnode.dev/measure-proxy-download-without-hiding-errors</link><guid isPermaLink="true">https://portproof.hashnode.dev/measure-proxy-download-without-hiding-errors</guid><category><![CDATA[networking]]></category><category><![CDATA[curl]]></category><category><![CDATA[Python]]></category><dc:creator><![CDATA[portproofas]]></dc:creator><pubDate>Thu, 08 Oct 2026 13:30:39 GMT</pubDate><content:encoded><![CDATA[<p><em>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.</em></p>
<p>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?</p>
<p>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.</p>
<p>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.</p>
<h2>A 200 MB sample and a refusal</h2>
<p>On 3 October 2026, a Portproof test recorded the following outcomes. The larger request asked for bytes <code>0-199999999</code>: <strong>200,000,000 bytes</strong>, or 200 decimal MB, approximately 190.7 MiB.</p>
<table>
<thead>
<tr>
<th>Observation</th>
<th>Earlier download</th>
<th>Separate download to another endpoint</th>
</tr>
</thead>
<tbody><tr>
<td>CONNECT response</td>
<td>200</td>
<td>200</td>
</tr>
<tr>
<td>Destination response</td>
<td>403</td>
<td>206</td>
</tr>
<tr>
<td>curl exit code</td>
<td>22</td>
<td>0</td>
</tr>
<tr>
<td>Downloaded bytes</td>
<td>0</td>
<td>200,000,000</td>
</tr>
<tr>
<td>File size checked after download</td>
<td>0</td>
<td>200,000,000</td>
</tr>
<tr>
<td>Total seconds</td>
<td>1.438772</td>
<td>35.192607</td>
</tr>
<tr>
<td>TLS verification result</td>
<td>0</td>
<td>0</td>
</tr>
</tbody></table>
<p>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.</p>
<p>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 <code>country_verified: false</code>; no exit address is published here.</p>
<h2>Choose a destination you control</h2>
<p>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.</p>
<p>The endpoint needs to return <code>206 Partial Content</code> 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.</p>
<p>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 <code>MAX_BYTES</code>; record the change beside the result.</p>
<h2>Run one request, with hidden credentials</h2>
<p>You need Python 3.9 or later and curl 8.4.0 or later on macOS or Linux. Check <code>python3 --version</code> and <code>curl --version</code> 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.</p>
<p>Save this as <code>measure-proxy.py</code>. 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.</p>
<pre><code class="language-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) &lt; 33 or ord(char) &gt; 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) &lt; 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())) &lt; (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.")
</code></pre>
<p>Run it in an interactive terminal:</p>
<pre><code class="language-bash">python3 measure-proxy.py
</code></pre>
<p>For the proxy URL, use the connection host and HTTP port from your account, for example <code>http://proxy.example.com:8080</code>. For the target, use your own HTTPS file URL. These are configuration values, not a claim that the example host exists.</p>
<p>The credential-derived Basic header is supplied with curl's <code>proxy-header</code> option through <code>--config -</code>. 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.</p>
<p>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.</p>
<p>The script ignores curl's default configuration, chooses the proxy explicitly and clears <code>NO_PROXY</code> exclusions for this request. URL globbing is disabled with <code>--globoff</code>, 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 <code>test_ca</code> function parameter belongs to the local verification harness, which creates its own certificate; the interactive command does not use it.</p>
<h2>Interpret the whole report</h2>
<p><code>completed_sample: true</code> 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.</p>
<p>If <code>completed_sample</code> 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; <code>curl_exit</code> is the underlying curl result.</p>
<table>
<thead>
<tr>
<th>Report</th>
<th>What it tells you</th>
</tr>
</thead>
<tbody><tr>
<td>CONNECT 407 or 502; destination 0</td>
<td>The requested tunnel was not established. Inspect the proxy response and account/client configuration.</td>
</tr>
<tr>
<td>CONNECT 200; destination 403</td>
<td>An HTTP refusal arrived after tunnel establishment. Check the destination's permitted access and request requirements.</td>
</tr>
<tr>
<td>CONNECT 200; destination 0; curl 60</td>
<td>Certificate verification failed before an HTTP response was received. Check the hostname, certificate chain and intended trust store.</td>
</tr>
<tr>
<td>CONNECT 200; destination 206; fewer bytes than requested</td>
<td>A partial response alone does not prove the sample completed.</td>
</tr>
<tr>
<td>curl 63</td>
<td>curl rejected a body above the configured size limit. Check whether the origin honoured the range.</td>
</tr>
</tbody></table>
<p>The <a href="https://curl.se/libcurl/c/CURLINFO_HTTP_CONNECTCODE.html">CONNECT response code</a> and the <a href="https://curl.se/libcurl/c/CURLINFO_RESPONSE_CODE.html">destination response code</a> describe different stages. A zero destination status means curl received no destination HTTP response; it is not an HTTP status sent by the server.</p>
<p>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 <a href="https://curl.se/docs/sslcerts.html">certificate verification enabled</a> while investigating.</p>
<h2>Check the interpretation locally</h2>
<p>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.</p>
<table>
<thead>
<tr>
<th>Local fixture</th>
<th>CONNECT</th>
<th>Destination</th>
<th>curl exit</th>
</tr>
</thead>
<tbody><tr>
<td>Proxy returns an authentication challenge</td>
<td>407</td>
<td>0</td>
<td>56</td>
</tr>
<tr>
<td>Proxy reports a gateway failure</td>
<td>502</td>
<td>0</td>
<td>56</td>
</tr>
<tr>
<td>Origin refuses a request inside an established tunnel</td>
<td>200</td>
<td>403</td>
<td>0</td>
</tr>
<tr>
<td>Origin answers inside an established tunnel</td>
<td>200</td>
<td>200</td>
<td>0</td>
</tr>
<tr>
<td>Origin certificate is not trusted</td>
<td>200</td>
<td>0</td>
<td>60</td>
</tr>
</tbody></table>
<p>These values were measured with curl 8.7.1. The fixture did not use <code>--fail</code>, so its destination 403 still had exit 0. The download wrapper <strong>does</strong> use <code>--fail</code>, so its destination 403 has exit 22. Recording the options resolves the apparent disagreement. Other curl versions can report different tunnel-failure exit codes.</p>
<p>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.</p>
<h2>Keep a useful record</h2>
<p>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.</p>
<p>For a shorter diagnostic, the <a href="https://portproof.org/guides/test-a-proxy-with-curl">curl proxy guide</a> shows a single-request check. The <a href="https://portproof.org/guides/connect-tunnel-failed">CONNECT troubleshooting guide</a> helps interpret tunnel failures. Once you have a representative sample, the <a href="https://portproof.org/tools/proxy-bandwidth-calculator">bandwidth calculator</a> turns your measured bytes into workload estimates; it does not measure success rates for you.</p>
<p>The curl options are documented in the <a href="https://curl.se/docs/manpage.html">official manual</a>. The important habit is to preserve a failed request alongside a successful one and label the scope of each observation.</p>
<p><em>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.</em></p>
]]></content:encoded></item></channel></rss>