From fe2d8896af1ce008ca29a2118620ea04da630ec6 Mon Sep 17 00:00:00 2001 From: RobLe3 Date: Sat, 29 Aug 2026 13:19:57 +0200 Subject: [PATCH] fix: bound HTTP task request and response bodies --- CHANGELOG.md | 6 + parity/http-task-resource-boundary-v1.json | 22 +++ src/iicp_client/_http.py | 94 ++++++++++- src/iicp_client/_http_resource.py | 182 +++++++++++++++++++++ src/iicp_client/errors.py | 6 +- src/iicp_client/node.py | 57 +++++-- tests/test_http_resource_boundary.py | 154 +++++++++++++++++ tests/test_serve.py | 27 +++ 8 files changed, 527 insertions(+), 21 deletions(-) create mode 100644 parity/http-task-resource-boundary-v1.json create mode 100644 src/iicp_client/_http_resource.py create mode 100644 tests/test_http_resource_boundary.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f873085..30503ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ in the main repo). ## [Unreleased] +### Security — bounded stable HTTP task path + +- Enforce the protocol's 1 MiB encoded request and response boundary for + `POST /v1/task`, including declared and chunked bodies, identity-only content + encoding, bounded provider output, and non-retryable oversize failures. + ## [0.7.110] — 2026-08-28 ### Changed — explicit qualification boundary diff --git a/parity/http-task-resource-boundary-v1.json b/parity/http-task-resource-boundary-v1.json new file mode 100644 index 0000000..ab8055f --- /dev/null +++ b/parity/http-task-resource-boundary-v1.json @@ -0,0 +1,22 @@ +{ + "fixture_version": "1.0.0", + "profile": "iicp-supported-http-task-resource-boundary-v1", + "scope": "POST /v1/task HTTP/JSON coordinated stable binding", + "max_encoded_request_bytes": 1048576, + "max_encoded_response_bytes": 1048576, + "supported_content_encodings": ["identity"], + "vectors": [ + {"id": "request_exact_limit", "direction": "request", "encoded_bytes": 1048576, "expected": "accept"}, + {"id": "request_limit_plus_one", "direction": "request", "encoded_bytes": 1048577, "expected": "reject", "http_status": 413, "error_code": "request_too_large", "retryable": false, "fallback": false}, + {"id": "declared_request_over_limit", "direction": "request", "content_length": 1048577, "expected": "reject_before_body_read", "http_status": 413, "error_code": "request_too_large"}, + {"id": "conflicting_content_length", "direction": "request", "content_length_values": [12, 13], "expected": "reject", "http_status": 400, "error_code": "invalid_http_body"}, + {"id": "chunked_exact_limit", "direction": "request", "transfer_encoding": "chunked", "encoded_bytes": 1048576, "expected": "accept"}, + {"id": "chunked_limit_plus_one", "direction": "request", "transfer_encoding": "chunked", "encoded_bytes": 1048577, "expected": "reject_on_overrun", "http_status": 413, "error_code": "request_too_large"}, + {"id": "unsupported_request_encoding", "direction": "request", "content_encoding": "gzip", "expected": "reject_before_decode", "http_status": 415, "error_code": "unsupported_content_encoding"}, + {"id": "malformed_json", "direction": "request", "expected": "reject", "http_status": 400, "error_code": "invalid_http_body"}, + {"id": "disconnect_before_declared_length", "direction": "request", "expected": "reject", "http_status": 400, "error_code": "invalid_http_body"}, + {"id": "response_exact_limit", "direction": "response", "encoded_bytes": 1048576, "expected": "accept"}, + {"id": "response_limit_plus_one", "direction": "response", "encoded_bytes": 1048577, "expected": "abort_and_reject", "error_code": "response_too_large", "retryable": false, "fallback": false}, + {"id": "generated_response_over_limit", "direction": "response", "encoded_bytes": 1048577, "expected": "replace_with_bounded_error", "http_status": 500, "error_code": "response_too_large", "retryable": false} + ] +} diff --git a/src/iicp_client/_http.py b/src/iicp_client/_http.py index 7915acf..2da712b 100644 --- a/src/iicp_client/_http.py +++ b/src/iicp_client/_http.py @@ -10,6 +10,12 @@ import httpx +from iicp_client._http_resource import ( + HttpTaskBodyError, + append_response_chunk, + bounded_request_json, + validate_response_headers, +) from iicp_client.endpoint_security import PinnedAsyncHTTPTransport, resolve_endpoint from iicp_client.errors import IicpError, from_http @@ -84,11 +90,26 @@ async def post_json( pin_provider_endpoint: bool = False, ) -> tuple[dict[str, Any], int]: """Returns (response_body, elapsed_ms).""" + try: + encoded = bounded_request_json(body) + except HttpTaskBodyError as exc: + raise IicpError( + code=exc.code, + message=exc.message, + component=component, + retryable=False, + http_status=exc.status, + ) from None timeout = (timeout_ms / 1000.0) + 2.0 - headers: dict[str, str] = {"traceparent": traceparent or _traceparent()} + headers: dict[str, str] = { + "traceparent": traceparent or _traceparent(), + "Content-Type": "application/json", + "Accept": "application/json", + } if extra_headers: headers.update(extra_headers) t0 = time.monotonic() + response_client: httpx.AsyncClient | None = None try: if pin_provider_endpoint: current = url @@ -96,15 +117,20 @@ async def post_json( for redirect_count in range(4): endpoint = await resolve_endpoint(current) transport = PinnedAsyncHTTPTransport(endpoint, verify=_tls_context(tls_verify)) - async with httpx.AsyncClient( + client = httpx.AsyncClient( timeout=timeout, transport=transport, follow_redirects=False, - ) as client: - resp = await client.post(current, json=body, headers=headers) + ) + response_client = client + request = client.build_request("POST", current, content=encoded, headers=headers) + resp = await client.send(request, stream=True) if resp.status_code in {307, 308}: location = resp.headers.get("location") if redirect_count == 3 or not location: + await resp.aclose() + await client.aclose() + response_client = None raise IicpError( code="IICP-ENDPOINT-REFUSED", message="provider redirect limit exceeded or omitted Location", @@ -116,27 +142,40 @@ async def post_json( parsed_next = httpx.URL(next_url) next_origin = (parsed_next.scheme, parsed_next.host, parsed_next.port) if next_origin != current_origin: + await resp.aclose() + await client.aclose() + response_client = None raise IicpError( code="IICP-ENDPOINT-REFUSED", message="cross-origin provider redirect is not allowed", component=component, retryable=False, ) + await resp.aclose() + await client.aclose() + response_client = None current = next_url continue if 300 <= resp.status_code < 400: + await resp.aclose() + await client.aclose() + response_client = None raise IicpError( code="IICP-ENDPOINT-REFUSED", message="provider redirect method is not allowed", component=component, retryable=False, ) + response_client = client break assert resp is not None else: - async with httpx.AsyncClient(timeout=timeout, verify=_tls_context(tls_verify)) as client: - resp = await client.post(url, json=body, headers=headers) + response_client = httpx.AsyncClient(timeout=timeout, verify=_tls_context(tls_verify)) + request = response_client.build_request("POST", url, content=encoded, headers=headers) + resp = await response_client.send(request, stream=True) except httpx.TimeoutException: + if response_client is not None: + await response_client.aclose() raise IicpError( code="IICP-E003", message=f"Request to {url} timed out after {timeout_ms}ms", @@ -144,16 +183,55 @@ async def post_json( retryable=True, ) from None except httpx.RequestError as exc: + if response_client is not None: + await response_client.aclose() raise IicpError( code="IICP-E004", message=f"Network error reaching {url}: {exc}", component=component, retryable=True, ) from exc + try: + validate_response_headers(resp.headers) + content = bytearray() + async for chunk in resp.aiter_raw(): + append_response_chunk(content, chunk) + except HttpTaskBodyError as exc: + raise IicpError( + code=exc.code, + message=exc.message, + component=component, + retryable=False, + http_status=exc.status, + ) from None + finally: + await resp.aclose() + if response_client is not None: + await response_client.aclose() elapsed = int((time.monotonic() - t0) * 1000) + try: + decoded = httpx.Response(resp.status_code, content=bytes(content)).json() + except Exception: + decoded = {"error": {"code": "invalid_http_body", "message": "provider returned invalid JSON"}} + if resp.is_success: + raise IicpError( + code="invalid_http_body", + message="provider returned invalid JSON", + component=component, + retryable=False, + http_status=500, + ) from None if not resp.is_success: - raise from_http(resp.status_code, _safe_json(resp), component) - return resp.json(), elapsed + raise from_http(resp.status_code, decoded, component) + if not isinstance(decoded, dict): + raise IicpError( + code="invalid_http_body", + message="provider response must be a JSON object", + component=component, + retryable=False, + http_status=500, + ) + return decoded, elapsed def _safe_json(resp: httpx.Response) -> dict: diff --git a/src/iicp_client/_http_resource.py b/src/iicp_client/_http_resource.py new file mode 100644 index 0000000..3d9db00 --- /dev/null +++ b/src/iicp_client/_http_resource.py @@ -0,0 +1,182 @@ +"""Finite resource boundary for the supported HTTP ``POST /v1/task`` binding.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Protocol + +MAX_HTTP_TASK_BODY_BYTES = 1_048_576 + + +@dataclass(frozen=True) +class HttpTaskBodyError(Exception): + status: int + code: str + message: str + close_connection: bool = False + + +class _HeadersLike(Protocol): + def get(self, name: str, default: Any = None) -> Any: ... + + +class _BinaryReader(Protocol): + def read(self, size: int = -1) -> bytes: ... + + def readline(self, size: int = -1) -> bytes: ... + + +def encode_task_json(value: Any) -> bytes: + return json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + + +def bounded_request_json(value: Any) -> bytes: + encoded = encode_task_json(value) + if len(encoded) > MAX_HTTP_TASK_BODY_BYTES: + raise HttpTaskBodyError( + 413, + "request_too_large", + f"encoded task request exceeds {MAX_HTTP_TASK_BODY_BYTES} bytes", + ) + return encoded + + +def bounded_response_json(value: Any) -> tuple[int, bytes]: + encoded = encode_task_json(value) + if len(encoded) <= MAX_HTTP_TASK_BODY_BYTES: + return 200, encoded + return 500, encode_task_json( + { + "error": { + "code": "response_too_large", + "message": f"encoded task response exceeds {MAX_HTTP_TASK_BODY_BYTES} bytes", + } + } + ) + + +def _header_values(headers: _HeadersLike, name: str) -> list[str]: + getter = getattr(headers, "get_all", None) + raw = getter(name) if callable(getter) else None + if raw is None: + value = headers.get(name) + raw = [] if value is None else [value] + values: list[str] = [] + for item in raw: + values.extend(part.strip() for part in str(item).split(",")) + return values + + +def _validate_content_encoding(headers: _HeadersLike) -> None: + encodings = [value.lower() for value in _header_values(headers, "Content-Encoding")] + if encodings and any(value != "identity" for value in encodings): + raise HttpTaskBodyError( + 415, + "unsupported_content_encoding", + "supported HTTP task binding accepts identity encoding only", + True, + ) + + +def _content_length(headers: _HeadersLike) -> int | None: + values = _header_values(headers, "Content-Length") + if not values: + return None + if any(not value.isascii() or not value.isdigit() for value in values): + raise HttpTaskBodyError(400, "invalid_http_body", "invalid Content-Length", True) + parsed = [int(value, 10) for value in values] + if len(set(parsed)) != 1: + raise HttpTaskBodyError(400, "invalid_http_body", "conflicting Content-Length", True) + return parsed[0] + + +def _read_exact(stream: _BinaryReader, count: int) -> bytes: + data = stream.read(count) + if len(data) != count: + raise HttpTaskBodyError(400, "invalid_http_body", "request body ended before declared length", True) + return data + + +def _read_chunked(stream: _BinaryReader) -> bytes: + body = bytearray() + while True: + line = stream.readline(8194) + if not line.endswith(b"\r\n") or len(line) > 8193: + raise HttpTaskBodyError(400, "invalid_http_body", "malformed chunk header", True) + size_text = line[:-2].split(b";", 1)[0].strip() + try: + size = int(size_text, 16) + except ValueError as exc: + raise HttpTaskBodyError(400, "invalid_http_body", "malformed chunk size", True) from exc + if size < 0: + raise HttpTaskBodyError(400, "invalid_http_body", "malformed chunk size", True) + if size == 0: + trailer_bytes = 0 + while True: + trailer = stream.readline(8194) + trailer_bytes += len(trailer) + if trailer == b"\r\n": + return bytes(body) + if not trailer.endswith(b"\r\n") or trailer_bytes > 8192: + raise HttpTaskBodyError(400, "invalid_http_body", "malformed chunk trailer", True) + if len(body) + size > MAX_HTTP_TASK_BODY_BYTES: + raise HttpTaskBodyError( + 413, + "request_too_large", + f"encoded task request exceeds {MAX_HTTP_TASK_BODY_BYTES} bytes", + True, + ) + body.extend(_read_exact(stream, size)) + if _read_exact(stream, 2) != b"\r\n": + raise HttpTaskBodyError(400, "invalid_http_body", "malformed chunk terminator", True) + + +def read_task_request_body(headers: _HeadersLike, stream: _BinaryReader) -> bytes: + _validate_content_encoding(headers) + content_length = _content_length(headers) + transfer = [value.lower() for value in _header_values(headers, "Transfer-Encoding")] + if transfer and content_length is not None: + raise HttpTaskBodyError( + 400, + "invalid_http_body", + "Content-Length and Transfer-Encoding cannot be combined", + True, + ) + if transfer: + if transfer != ["chunked"]: + raise HttpTaskBodyError(400, "invalid_http_body", "unsupported Transfer-Encoding", True) + return _read_chunked(stream) + if content_length is None: + return b"" + if content_length > MAX_HTTP_TASK_BODY_BYTES: + raise HttpTaskBodyError( + 413, + "request_too_large", + f"encoded task request exceeds {MAX_HTTP_TASK_BODY_BYTES} bytes", + True, + ) + return _read_exact(stream, content_length) + + +def validate_response_headers(headers: _HeadersLike) -> None: + _validate_content_encoding(headers) + content_length = _content_length(headers) + if content_length is not None and content_length > MAX_HTTP_TASK_BODY_BYTES: + raise HttpTaskBodyError( + 500, + "response_too_large", + f"encoded task response exceeds {MAX_HTTP_TASK_BODY_BYTES} bytes", + True, + ) + + +def append_response_chunk(buffer: bytearray, chunk: bytes) -> None: + if len(buffer) + len(chunk) > MAX_HTTP_TASK_BODY_BYTES: + raise HttpTaskBodyError( + 500, + "response_too_large", + f"encoded task response exceeds {MAX_HTTP_TASK_BODY_BYTES} bytes", + True, + ) + buffer.extend(chunk) diff --git a/src/iicp_client/errors.py b/src/iicp_client/errors.py index e576e32..62096a5 100644 --- a/src/iicp_client/errors.py +++ b/src/iicp_client/errors.py @@ -34,8 +34,10 @@ def __repr__(self) -> str: def from_http(status: int, body: dict, component: str) -> IicpError: """Build a typed IicpError from an HTTP response body.""" - code = body.get("code", f"IICP-E{status:03d}") - message = body.get("message", body.get("error", "Unexpected error")) + raw_error = body.get("error") + nested: dict = raw_error if isinstance(raw_error, dict) else {} + code = body.get("code") or nested.get("code") or f"IICP-E{status:03d}" + message = body.get("message") or nested.get("message") or "Unexpected error" return IicpError( code=code, message=message, diff --git a/src/iicp_client/node.py b/src/iicp_client/node.py index b92b562..874b4eb 100644 --- a/src/iicp_client/node.py +++ b/src/iicp_client/node.py @@ -1564,6 +1564,22 @@ def _prometheus(self) -> None: # ── POST /v1/task ───────────────────────────────────────────── def _task(self) -> None: + from iicp_client._http_resource import ( + HttpTaskBodyError, + bounded_response_json, + encode_task_json, + read_task_request_body, + ) + + try: + raw_body = read_task_request_body(self.headers, self.rfile) + except HttpTaskBodyError as exc: + self.close_connection = self.close_connection or exc.close_connection + self._json_response( + exc.status, + encode_task_json({"error": {"code": exc.code, "message": exc.message}}), + ) + return # F4 (#524) — rate-limit browser-origin task dispatch (the # CORS confused-deputy vector) only. Non-browser callers send no # Origin and are the operator's own authed traffic — never throttled. @@ -1584,10 +1600,16 @@ def _task(self) -> None: # Read the body first so QoS-aware admission can see # constraints.qos_class before deciding whether to wait for a slot. try: - length = int(self.headers.get("Content-Length", 0)) - body: dict[str, Any] = json.loads(self.rfile.read(length)) if length else {} + body: dict[str, Any] = json.loads(raw_body) if raw_body else {} + if not isinstance(body, dict): + raise ValueError("task body must be an object") except (ValueError, json.JSONDecodeError): - self.send_error(400, "invalid JSON body") + self._json_response( + 400, + encode_task_json( + {"error": {"code": "invalid_http_body", "message": "invalid JSON body"}} + ), + ) return constraints = body.get("constraints") or {} @@ -1780,11 +1802,6 @@ def _task(self) -> None: latency_ms = (time.monotonic() - t0) * 1000 usage = result.get("usage") or {} tokens = usage.get("total_tokens", 0) if isinstance(usage, dict) else 0 - node._metrics.observe("completed", intent, qos, latency_ms, tokens) - with node._task_counters_lock: - node._tasks_success += 1 - if latency_ms > 0: - node._tasks_latency_total_ms += latency_ms response = { "task_id": task_id, "status": "completed", @@ -1799,8 +1816,21 @@ def _task(self) -> None: "status": "encrypted", "iicp_conf_resp": encrypt_response(response, cx_shared_secret, task_id), } - resp_body = json.dumps(response).encode() - self._json_response(200, resp_body) + response_status, resp_body = bounded_response_json(response) + if response_status != 200: + node._metrics.observe("error", intent, qos, latency_ms) + with node._task_counters_lock: + node._tasks_failed += 1 + if latency_ms > 0: + node._tasks_latency_total_ms += latency_ms + self._json_response(response_status, resp_body) + return + node._metrics.observe("completed", intent, qos, latency_ms, tokens) + with node._task_counters_lock: + node._tasks_success += 1 + if latency_ms > 0: + node._tasks_latency_total_ms += latency_ms + self._json_response(response_status, resp_body) # TC-9c: fire best-effort CIPWorkerReceipt to the directory. # Server-side award path: provider reports completion so the # directory credits the wallet without proxy forwarding. @@ -1833,7 +1863,12 @@ def _task(self) -> None: if latency_ms > 0: node._tasks_latency_total_ms += latency_ms logger.error("Handler error: %s", exc) - self.send_error(500, str(exc)) + self._json_response( + 500, + encode_task_json( + {"error": {"code": "backend_error", "message": "task execution failed"}} + ), + ) finally: node._sem.release() with node._jobs_lock: diff --git a/tests/test_http_resource_boundary.py b/tests/test_http_resource_boundary.py new file mode 100644 index 0000000..6bcfe9b --- /dev/null +++ b/tests/test_http_resource_boundary.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import asyncio +import io +import json +from pathlib import Path + +import pytest + +from iicp_client._http import post_json +from iicp_client._http_resource import ( + MAX_HTTP_TASK_BODY_BYTES, + HttpTaskBodyError, + bounded_request_json, + bounded_response_json, + encode_task_json, + read_task_request_body, +) +from iicp_client.errors import IicpError + + +class _Headers(dict[str, str]): + def __init__(self, values: dict[str, list[str]]) -> None: + super().__init__({key: items[-1] for key, items in values.items()}) + self._values = values + + def get_all(self, name: str) -> list[str] | None: + for key, values in self._values.items(): + if key.lower() == name.lower(): + return values + return None + + +class _NoRead(io.BytesIO): + def read(self, size: int = -1) -> bytes: + raise AssertionError("declared oversize body must not be read") + + +def _json_value_with_size(size: int) -> dict[str, str]: + overhead = len(encode_task_json({"padding": ""})) + assert size >= overhead + value = {"padding": "x" * (size - overhead)} + assert len(encode_task_json(value)) == size + return value + + +def _chunked(payload: bytes) -> bytes: + return f"{len(payload):X}\r\n".encode() + payload + b"\r\n0\r\n\r\n" + + +def test_request_exact_limit_is_accepted_and_limit_plus_one_is_rejected() -> None: + exact = _json_value_with_size(MAX_HTTP_TASK_BODY_BYTES) + assert len(bounded_request_json(exact)) == MAX_HTTP_TASK_BODY_BYTES + with pytest.raises(HttpTaskBodyError, match="request_too_large"): + bounded_request_json(_json_value_with_size(MAX_HTTP_TASK_BODY_BYTES + 1)) + + +def test_shared_fixture_matches_implementation_boundary() -> None: + fixture = json.loads( + (Path(__file__).resolve().parents[1] / "parity/http-task-resource-boundary-v1.json").read_text() + ) + assert fixture["max_encoded_request_bytes"] == MAX_HTTP_TASK_BODY_BYTES + assert fixture["max_encoded_response_bytes"] == MAX_HTTP_TASK_BODY_BYTES + assert fixture["supported_content_encodings"] == ["identity"] + + +def test_declared_oversize_is_rejected_before_read() -> None: + headers = _Headers({"Content-Length": [str(MAX_HTTP_TASK_BODY_BYTES + 1)]}) + with pytest.raises(HttpTaskBodyError) as caught: + read_task_request_body(headers, _NoRead()) + assert caught.value.status == 413 + assert caught.value.code == "request_too_large" + + +def test_conflicting_content_length_and_unsupported_encoding_are_rejected() -> None: + with pytest.raises(HttpTaskBodyError) as conflicting: + read_task_request_body(_Headers({"Content-Length": ["12", "13"]}), io.BytesIO()) + assert (conflicting.value.status, conflicting.value.code) == (400, "invalid_http_body") + with pytest.raises(HttpTaskBodyError) as encoded: + read_task_request_body( + _Headers({"Content-Length": ["0"], "Content-Encoding": ["gzip"]}), + io.BytesIO(), + ) + assert (encoded.value.status, encoded.value.code) == (415, "unsupported_content_encoding") + + +def test_chunked_body_is_counted_incrementally() -> None: + exact = b"x" * MAX_HTTP_TASK_BODY_BYTES + headers = _Headers({"Transfer-Encoding": ["chunked"]}) + assert read_task_request_body(headers, io.BytesIO(_chunked(exact))) == exact + with pytest.raises(HttpTaskBodyError) as caught: + read_task_request_body(headers, io.BytesIO(_chunked(exact + b"x"))) + assert (caught.value.status, caught.value.code) == (413, "request_too_large") + + +def test_truncated_declared_body_is_rejected() -> None: + with pytest.raises(HttpTaskBodyError) as caught: + read_task_request_body(_Headers({"Content-Length": ["4"]}), io.BytesIO(b"{}")) + assert (caught.value.status, caught.value.code) == (400, "invalid_http_body") + + +def test_generated_response_is_bounded() -> None: + status, exact = bounded_response_json(_json_value_with_size(MAX_HTTP_TASK_BODY_BYTES)) + assert status == 200 + assert len(exact) == MAX_HTTP_TASK_BODY_BYTES + status, replacement = bounded_response_json(_json_value_with_size(MAX_HTTP_TASK_BODY_BYTES + 1)) + assert status == 500 + assert len(replacement) < MAX_HTTP_TASK_BODY_BYTES + assert json.loads(replacement)["error"]["code"] == "response_too_large" + + +@pytest.mark.asyncio +async def test_client_rejects_oversize_request_before_network() -> None: + with pytest.raises(IicpError) as caught: + await post_json( + "http://127.0.0.1:9/v1/task", + _json_value_with_size(MAX_HTTP_TASK_BODY_BYTES + 1), + tls_verify=False, + ) + assert caught.value.code == "request_too_large" + assert caught.value.retryable is False + assert caught.value.http_status == 413 + + +@pytest.mark.asyncio +async def test_client_aborts_declared_oversize_response_as_non_retryable() -> None: + async def respond(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + await reader.read(4096) + writer.write( + ( + "HTTP/1.1 200 OK\r\n" + "Content-Type: application/json\r\n" + f"Content-Length: {MAX_HTTP_TASK_BODY_BYTES + 1}\r\n" + "Connection: close\r\n\r\n" + ).encode() + ) + await writer.drain() + writer.close() + + server = await asyncio.start_server(respond, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + try: + with pytest.raises(IicpError) as caught: + await post_json( + f"http://127.0.0.1:{port}/v1/task", + {"task_id": "bounded"}, + tls_verify=False, + ) + assert caught.value.code == "response_too_large" + assert caught.value.retryable is False + assert caught.value.http_status == 500 + finally: + server.close() + await server.wait_closed() diff --git a/tests/test_serve.py b/tests/test_serve.py index debf36a..f6c9163 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -20,6 +20,7 @@ import pytest from iicp_client import IicpNode, NodeConfig +from iicp_client._http_resource import MAX_HTTP_TASK_BODY_BYTES # ── helpers ───────────────────────────────────────────────────────────────── @@ -235,6 +236,32 @@ def test_unknown_post_path_404(self, srv: _ServerHandle): status, _, _ = srv.post("/bad-path", {}) assert status == 404 + def test_declared_oversize_is_rejected_before_task_execution(self, srv: _ServerHandle): + conn = HTTPConnection("127.0.0.1", srv.port, timeout=3) + conn.request( + "POST", + "/v1/task", + body=b"", + headers={ + "Content-Type": "application/json", + "Content-Length": str(MAX_HTTP_TASK_BODY_BYTES + 1), + }, + ) + response = conn.getresponse() + body = json.loads(response.read()) + conn.close() + assert response.status == 413 + assert body["error"]["code"] == "request_too_large" + + def test_unsupported_content_encoding_is_rejected(self, srv: _ServerHandle): + status, body, _ = srv.post( + "/v1/task", + {"task_id": "encoded", "intent": "x", "payload": {}}, + {"Content-Encoding": "gzip"}, + ) + assert status == 415 + assert body["error"]["code"] == "unsupported_content_encoding" + # ── Concurrency gate (IICP-E021) ─────────────────────────────────────────────