Skip to content

GitHub HTTP response status is lost on truncated body reads, leaving request budget blind #109

Open

Description

@andy-zen-dev

Verified adjacent implementation finding (2026-10-30)

Source boundary: release/0.6.0 at 6d935ee111e2efab7a5f7d7c8f8d050af8d35567; src/devostasis/adapters/github.py blob 7a998b94b12547ef543a6320360ed94f088197a7. Related but distinct from #108, which covers completed HTTP responses with malformed JSON; this case concerns a response whose HTTP 200 status and headers have arrived, but whose body read fails. This is not a new Vital, threshold, or policy change.

Reproduction and precise boundary

UrllibTransport.get() obtains a response object in with self._open(request) as response:, then calls response.read() before returning (response.status, headers, body). If response.read() raises http.client.IncompleteRead, the outer except (urllib.error.URLError, http.client.HTTPException, TimeoutError, OSError) raises NetworkFailure without propagating the already observed status or headers. GitHubClient._transport_get() has already incremented request_count, but GitHubClient.get() never reaches its billed_count += 1 path; the next _claim_request() checks only billed_count.

Offline native stdlib loopback proof: a local HTTP server sends 200, Content-Length: 100, and only two body bytes ([]), then closes. Python urllib returns a response with status == 200, while response.read() raises IncompleteRead(2 bytes read, 98 more expected). Repeated twice: 2/2 observed HTTP 200 header responses and 2/2 IncompleteRead, with the server confirming both requests. A separate source-shaped extraction of the transport/client budget control flow with budget=1 performed two dispatches, request_count=2, billed_count=0: a physical request cap was not enforced. Both probes used only the loopback interface and no external GitHub calls.

Minimal reproducer for the native read boundary:

# Inside a local BaseHTTPRequestHandler.do_GET():
self.send_response(200)
self.send_header("Content-Length", "100")
self.end_headers()
self.wfile.write(b"[]")
self.close_connection = True

# Inside the client:
with urllib.request.urlopen(local_url, timeout=2) as response:
    assert response.status == 200
    response.read()  # raises http.client.IncompleteRead

Impact / uncertainty

A status-known-but-truncated response is recorded as a generic NetworkFailure, so billing evidence visible before the read failure is discarded. An API provider may charge such a request; the loopback experiment does not prove GitHub's accounting or a real production billing incident. In this path, a configured billed-response budget does not bound the number of physical dispatches across separate get() calls, even when status=200 was observed each time. The collector does not convert the truncated body into a successful exact inventory; this finding concerns liveness, provenance and request accounting, not false AVAILABLE metrics.

Requested repair / acceptance

  1. Preserve available HTTP status/headers through a truncated-body read failure (or an equivalent typed transport outcome) without accepting or caching partial JSON. Keep ERROR/explicit incomplete evidence and existing read-only transport behavior.
  2. Add real UrllibTransport + GitHubClient regressions using a local or fake response that raises IncompleteRead after an observed HTTP 200: check counters, subsequent dispatch eligibility, cache integrity, and run_meta under a bounded budget. Include an unknown-status network failure control and 304 exemption.
  3. Decide explicitly whether known HTTP status is sufficient evidence for charging billed_requests, and/or whether an independent physical-attempt cap is needed. Do not silently treat unknown provider billing as zero or change existing budget semantics without an accepted contract/compatibility decision. GitHub transport: malformed completed responses bypass billed-request budget #108's completed-body fix is orthogonal.
  4. Run full offline pytest, sample/historical verification, installed-wheel and exact-head CI before claiming release acceptance.

Evidence boundary: native stdlib loopback behavior and source-shaped Devostasis control-flow reproduction passed; full installed Devostasis, GitHub provider quota behavior, pytest, conformance and hosted CI were NOT_RUN. No repository code change or merge is claimed. Dedup search of open/closed issues/PRs for IncompleteRead and truncated-body billing found no dedicated issue; #108 covers completed JSON decode failures instead.

Activity

  1. andy-zen-dev commented on Oct 30, 2026

    @andy-zen-dev
    Author

    #109 implementation handoff — retain known HTTP status across failed body reads (2026-10-30)

    Pinned evidence: release/0.6.0 at 6d935ee111e2efab7a5f7d7c8f8d050af8d35567, src/devostasis/adapters/github.py blob 7a998b94b12547ef543a6320360ed94f088197a7; CONTRIBUTING.md was consulted. Authenticated account andy-zen-dev, permission pull=true,push=false,triage=false,maintain=false,admin=false. No source mutation was possible.

    Prepared narrow prerequisite fix (NOT integrated): Keep the existing NetworkFailure exception class and add optional status_code/response_headers metadata. In UrllibTransport.get(), capture the actual response status and normalized headers before calling response.read(); wrap only failures occurring after that point in NetworkFailure(..., status_code=status, response_headers=headers). The failed body is not returned, parsed, or cached. Errors before headers remain status-less. Normal 200/JSON handling and the 304 cache exemption remain unchanged. Existing callers constructing NetworkFailure(message) remain compatible.

    Key source edits:

    @@ -86,6 +86,10 @@
     @dataclass
     class NetworkFailure(Exception):
         message: str
    +    # HTTP status and headers are evidence, even when the body never arrives.
    +    # None means dispatch failed before response headers could be observed.
    +    status_code: int | None = None
    +    response_headers: dict[str, str] | None = None
     
    @@ -175,8 +179,18 @@
             try:
                 with self._open(request) as response:
    -                raw = response.read()
    -                headers = {k.lower(): v for k, v in response.headers.items()}
    +                status = response.status
    +                response_headers = {k.lower(): v for k, v in response.headers.items()}
    +                try:
    +                    raw = response.read()
    +                except (urllib.error.URLError, http.client.HTTPException, TimeoutError, OSError) as exc:
    +                    raise NetworkFailure(
    +                        f"network failure for {path} after HTTP {status}: {type(exc).__name__}: {exc}",
    +                        status_code=status,
    +                        response_headers=response_headers,
    +                    ) from exc
                     try:
    @@
    -                    raise ApiFailure(response.status, ERROR, "MALFORMED_RESPONSE", f"{path} answered {response.status} with a body that is not readable JSON: {type(exc).__name__}") from exc
    -                return response.status, headers, body
    +                    raise ApiFailure(status, ERROR, "MALFORMED_RESPONSE", f"{path} answered {status} with a body that is not readable JSON: {type(exc).__name__}") from exc
    +                return status, response_headers, body

    Prepared standard unified patch also includes the new tests/test_github_transport_provenance.py (5 parametrized/standalone test cases): observed 200 with IncompleteRead or TimeoutError retains the known status and headers, cannot poison a preexisting ETag cache, a pre-header URLError remains unknown-status, a complete 200 still bills once, and a valid cached 304 still replays without billing. Patch SHA-256: 57800cd6fbea02e9776507f785e95840c9be629de82ec2905746183569965734.

    Verification performed: git apply --check, patch application, and test-module syntax: PASS on a synthetic reconstruction of the exact GitHub source hunk contexts, not a full checkout. Additional 5/5 isolated smoke scenarios PASS against source-extracted patched UrllibTransport.get for truncated read, post-header timeout, pre-header failure, valid 200 and malformed 200. Full real-package pytest, conformance, persisted-bundle verification, installed wheel, exact-head GitHub CI: NOT_RUN. No PR or merge was created.

    for:researcher — genuine pending contract decision: A response with observed HTTP 200 headers but an incomplete body might have consumed provider quota. Should the billed_requests metric charge an observed non-304 status before body completion, or should such calls remain charge unknown and be bounded separately by a physical-attempt cap? Decide and version the definition, required run-metadata/provenance, and compatibility impact before modifying GitHubClient.billed_count or _claim_request(). This prerequisite patch intentionally does not fix #109's request-budget escape until the accepted contract specifies charging/attempt limits; it only preserves the evidence needed to implement the correct choice. #108's completed-body accounting is a distinct accepted path.

    Suggested next implementation unit after decision: handle NetworkFailure.status_code in the client at the correct exactly-once charging boundary, add the real UrllibTransport + GitHubClient regression at budget=1 asserting second-dispatch behavior and run_meta, then run the complete repository gates before release admission.

  2. andy-zen-dev commented on Oct 30, 2026

    @andy-zen-dev
    Author

    #109 follow-up: combined #105–#109 implementation-stack compatibility, with an explicit remaining budget escape (2026-10-30)

    Scope and authority: Independent uncommitted integration check. Re-read release/0.6.0 src/devostasis/adapters/github.py blob 7a998b94b12547ef543a6320360ed94f088197a7, src/devostasis/adapters/cache.py blob 0d4de1e063eb6b0527b5b7b44c7fa0a7e830d085, CONTRIBUTING.md; observed GitHub account andy-zen-dev, pull=true,push=false,triage=false,maintain=false,admin=false. This is not a release acceptance, new research judgement, merge, or change to upstream.

    Prepared patches on the same pinned source:

    New exact-scope verification:

    1. Reconstructed original hunk contexts at the real source line offsets for all three patches; detected no conflicting original lines across seven touched paths. git apply --check, sequential application, and git diff --check PASS in all six patch orders. Both added test modules compile. This does not prove applicability in a complete checkout, only the reconstructed contexts.
    2. 21/21 isolated control-flow interactions PASS using the source-extracted GitHubClient.get algorithm plus scripted transport: known-status malformed JSON 200/202/206 is charged exactly once and never cached; returned unexpected 202/206/302/307 is rejected and charged; complete 200 and valid cached 304 retain their distinct accounting; status-aware truncated-body NetworkFailure propagates without accepting/caching its body.
    3. Critical non-GREEN acceptance condition remains: under these prepared patches, a response with HTTP 200 headers followed by IncompleteRead now preserves status metadata, but the client still does not charge billed_count or cap physical attempts. With budget=1, an isolated source-shaped test sees request_count=1,billed_count=0 after that failure and permits a second physical dispatch (request_count=2,billed_count=1 after a subsequent 200). Repeated incomplete reads remain unbounded by the billed-response budget. This was deliberate: the for:researcher decision requested earlier in GitHub HTTP response status is lost on truncated body reads, leaving request budget blind #109 is still unanswered. Do not close GitHub HTTP response status is lost on truncated body reads, leaving request budget blind #109 or characterize these tests as a fixed budget escape.

    Boundary: all synthetic reconstructions/extracted-algorithm checks, not installed Devostasis. Full pytest, conformance, sample/historical bundles, wheel and exact-head hosted CI are NOT_RUN. No branch/commit/PR/merge. The complete repro scripts are local work artifacts, not GitHub-persisted files.

    Next actionable unit once research decides charging semantics: apply the three non-overlapping patches to a real fix branch; implement and test #109's budget accounting/physical-attempt limit according to the accepted versioned decision, including real UrllibTransport + GitHubClient and run_meta regression at budget=1, then run the full repository gates and obtain independent exact-head review. #95 is separately approved but not merged; #81 remains a release dependency.

  3. andy-zen-dev commented on Oct 30, 2026

    @andy-zen-dev
    Author

    Redundancy correction — integration replay of prepared HTTP patch stack (2026-10-30)

    This run independently reran the three prepared patch packages on an exact-hunk synthetic reconstruction at release/0.6.0 source blob 7a998b94b12547ef543a6320360ed94f088197a7. All six patch application orders passed git apply --check, application, git diff --check, and syntax compilation of the added pytest modules. Replay script SHA-256: 1432edbf36dee91642d28eddcf2526b13743004c7019c255d07afd4046cfb13e. Re-executed separate isolated status-gate and #105–#108 interaction probes (109/109 and 16/16).

    Correction: the immediately preceding #109 comment #6523660138 already reports the same six-order compatibility test plus a stronger 21/21 interaction matrix and the unresolved physical-budget escape. This replay does not establish a new code fix, research judgement, release gate, or material improvement beyond that handoff. Treat the preceding comment as the authoritative integration evidence and avoid duplicate implementation work.

    The current #109 patch preserves an observed HTTP status on truncated body reads but does not enforce a physical-attempt cap or account for its possibly billed status. Research decision is still pending. Full checkout, installed-package pytest, historical bundles, wheel and exact-head CI: NOT_RUN. GitHub identity andy-zen-dev has pull=true, push=false, triage=false, maintain=false, admin=false; no branch, commit, PR, review verdict, or merge was produced.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions