Skip to content

Commit c6f967f

Browse files
committed
fix(sdk): isolate metadata requests and bound streamed responses
1 parent ae7961a commit c6f967f

9 files changed

Lines changed: 571 additions & 53 deletions

File tree

‎README.md‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,9 +203,10 @@ The Agent module also provides:
203203
- Conditional request and representation caching with injectable `Cache` and `Transport` protocols.
204204

205205
Default fallback cache lifetimes are four hours for Service documents, one hour for Collections,
206-
and five minutes for Offerings. HTTP cache directives take precedence. Provide distinct `transport`
207-
and `supporting_transport` instances when protocol resources and linked schemas require different
208-
credentials or network policy.
206+
and five minutes for Offerings. HTTP cache directives take precedence. `ServiceClient` uses a
207+
separate anonymous transport for linked schemas and OpenAPI documents, even when its primary
208+
`transport` has authentication configured. An explicit `supporting_transport` override must also
209+
send these requests anonymously; it must not share the primary transport's credentials or cookies.
209210

210211
### Search across Services
211212

@@ -269,6 +270,12 @@ must survive process restarts or share storage across workers. A custom `Transpo
269270
asynchronous `send()` and `aclose()` methods. Caller-provided caches and transports remain owned by
270271
the caller.
271272

273+
`HttpRequest.maximum_response_bytes` gives a custom transport the response budget. Enforce it while
274+
reading, rather than buffering the complete response first. The built-in transport closes responses
275+
on overflow, read failure, and cancellation. A successful response exceeding its budget raises
276+
`TransportError` with `code="RESPONSE_LIMIT_EXCEEDED"`; `ServiceClient` preserves that code on
277+
`AgentError`. Oversized error bodies are discarded while retaining the HTTP status and headers.
278+
272279
The built-in HTTP transport resolves and validates every destination before connecting, pins the
273280
connection to a validated public address, does not inherit proxy settings from the environment, and
274281
sends supporting-document requests without credentials. A custom transport must preserve those ODP
@@ -348,7 +355,9 @@ Directory results filter unrecognized enrollment, payment, and trust descriptors
348355
strict validation for recognized descriptors.
349356

350357
Individual Offering and Collection GETs default to full representations; list and search operations
351-
default to terse items. A Catalog receives the requested language in `CatalogRequest.language` and
358+
default to terse items. The Service handler writes `odp_version` on standalone resources and page
359+
envelopes, omitting it from embedded page items without changing the Catalog's models.
360+
A Catalog receives the requested language in `CatalogRequest.language` and
352361
declares the language it actually returns on each resource. Static catalogs do not translate content.
353362

354363
Refinement parsing detects duplicate JSON values without guessing the type of a string. Comparing
@@ -367,6 +376,10 @@ Protocol models preserve additive members in `model.additional` and round-trip t
367376
`model.to_dict()`. Parsing remains strict for normative constraints and fields that prohibit unknown
368377
members.
369378

379+
Directory records retain additional metadata such as branding and MCP endpoints. These are discovery
380+
hints, not authorization or authoritative routing data. The default Agent factory uses the record's
381+
`service_origin` and retrieves that Service's own document before making catalog requests.
382+
370383
Handle the narrowest error that the application can act upon and use the role's base error for the
371384
remaining failures:
372385

‎src/offering_protocol/agent/client.py‎

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ class TraversalOptions:
9191
class AgentError(RuntimeError):
9292
"""Base error for Service discovery operations."""
9393

94+
def __init__(self, message: str, *, code: str | None = None) -> None:
95+
super().__init__(message)
96+
self.code = code
97+
9498

9599
class UnsupportedOperationError(AgentError):
96100
def __init__(self, operation: Operation) -> None:
@@ -132,7 +136,12 @@ def __init__(
132136
self._cache_partition = cache_partition
133137
self._owns_transport = transport is None
134138
self._transport = transport or HttpxTransport(allow_local_network=allow_local_network)
135-
self._supporting_transport = supporting_transport or self._transport
139+
self._owns_supporting_transport = supporting_transport is None
140+
self._supporting_transport = (
141+
supporting_transport
142+
if supporting_transport is not None
143+
else HttpxTransport(allow_local_network=allow_local_network)
144+
)
136145

137146
async def __aenter__(self) -> ServiceClient:
138147
return self
@@ -141,8 +150,12 @@ async def __aexit__(self, *args: object) -> None:
141150
await self.aclose()
142151

143152
async def aclose(self) -> None:
144-
if self._owns_transport:
145-
await self._transport.aclose()
153+
try:
154+
if self._owns_transport:
155+
await self._transport.aclose()
156+
finally:
157+
if self._owns_supporting_transport:
158+
await self._supporting_transport.aclose()
146159

147160
async def inspect(self) -> Inspection:
148161
requested_url = f"{self.service_origin}/.well-known/odp"
@@ -399,7 +412,9 @@ async def _request_cached(
399412
headers["if-none-match"] = cached.etag
400413
if cached.last_modified:
401414
headers["if-modified-since"] = cached.last_modified
402-
response, final_url = await self._request_raw(method, request_target, body, headers)
415+
response, final_url = await self._request_raw(
416+
method, request_target, body, headers, maximum_bytes
417+
)
403418
if response.status == 304:
404419
if cached is None:
405420
raise AgentError("ODP response returned 304 without a cached representation")
@@ -435,7 +450,7 @@ async def _request_cached(
435450
return _FetchedResponse(response.body, final_url, Freshness.FETCHED)
436451

437452
async def _request_raw(
438-
self, method: str, target: str, body: bytes, conditional: dict[str, str]
453+
self, method: str, target: str, body: bytes, conditional: dict[str, str], maximum_bytes: int
439454
) -> tuple[HttpResponse, str]:
440455
redirect_origin = derive_service_origin(target)
441456
for redirects in range(_MAXIMUM_REDIRECTS + 1):
@@ -445,9 +460,11 @@ async def _request_raw(
445460
if body:
446461
headers["content-type"] = MEDIA_TYPE
447462
try:
448-
response = await self._transport.send(HttpRequest(method, target, headers, body))
463+
response = await self._transport.send(
464+
HttpRequest(method, target, headers, body, maximum_response_bytes=maximum_bytes)
465+
)
449466
except TransportError as error:
450-
raise AgentError(f"ODP Service request failed: {error}") from error
467+
raise AgentError(f"ODP Service request failed: {error}", code=error.code) from error
451468
if response.status not in {301, 302, 303, 307, 308}:
452469
return response, target
453470
if redirects == _MAXIMUM_REDIRECTS:
@@ -499,10 +516,17 @@ async def _supporting_json(
499516
visited.add(current)
500517
try:
501518
response = await self._supporting_transport.send(
502-
HttpRequest("GET", current, {"accept": accept, **conditional})
519+
HttpRequest(
520+
"GET",
521+
current,
522+
{"accept": accept, **conditional},
523+
maximum_response_bytes=maximum_bytes,
524+
)
503525
)
504526
except TransportError as error:
505-
raise AgentError(f"ODP supporting document request failed: {error}") from error
527+
raise AgentError(
528+
f"ODP supporting document request failed: {error}", code=error.code
529+
) from error
506530
if response.status in {301, 302, 303, 307, 308}:
507531
if redirects == _MAXIMUM_REDIRECTS:
508532
raise AgentError("ODP supporting document exceeded five redirects")
@@ -544,7 +568,9 @@ async def _supporting_json(
544568
response.headers,
545569
)
546570
if len(response.body) > maximum_bytes:
547-
raise AgentError("ODP supporting document exceeds its byte limit")
571+
raise AgentError(
572+
"ODP supporting document exceeds its byte limit", code="RESPONSE_LIMIT_EXCEEDED"
573+
)
548574
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
549575
if content_type not in media_types:
550576
raise AgentError("ODP supporting document has an unsupported media type")
@@ -643,7 +669,7 @@ def _consume(response: HttpResponse, maximum_bytes: int, maximum_depth: int) ->
643669
if not 200 <= response.status < 300:
644670
raise ServiceRequestError(response.status, _problem_message(response), response.headers)
645671
if len(response.body) > maximum_bytes:
646-
raise AgentError("ODP response exceeds its byte limit")
672+
raise AgentError("ODP response exceeds its byte limit", code="RESPONSE_LIMIT_EXCEEDED")
647673
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
648674
if content_type != MEDIA_TYPE:
649675
raise AgentError(f"ODP response must use {MEDIA_TYPE}")

‎src/offering_protocol/directory/client.py‎

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,6 @@
4242
_RFC_3339 = re.compile(
4343
r"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$"
4444
)
45-
#: Service Document members a Directory result may echo that this client does not validate.
46-
#:
47-
#: They are dropped rather than passed through, because a caller reading them off a record has no
48-
#: way to tell they were never checked. `http` is the one that matters most: a caller could build
49-
#: request URLs from an `endpoint_base` a Directory made up.
50-
_UNVERIFIED_MEMBERS = (
51-
"branding",
52-
"http",
53-
"mcp",
54-
"odp_version",
55-
"payment_origins",
56-
"search_capabilities",
57-
)
5845

5946

6047
class DirectoryError(RuntimeError):
@@ -288,8 +275,7 @@ def _read_service(entry: object) -> dict[str, object]:
288275
document = _validate_as_service_document(item)
289276
# `protocols` is reinstated from the validated document only when something survived agent
290277
# filtering, so a block naming nothing this ODP version knows does not pass straight through.
291-
for member in (*_UNVERIFIED_MEMBERS, "protocols"):
292-
item.pop(member, None)
278+
item.pop("protocols", None)
293279
if document.protocols is not None:
294280
item["protocols"] = document.protocols.model_dump(mode="json", exclude_defaults=True)
295281
item["operations"] = [operation.model_dump(mode="json") for operation in document.operations]

‎src/offering_protocol/directory/transport.py‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class HttpRequest:
2020
url: str
2121
headers: dict[str, str] = field(default_factory=dict)
2222
body: bytes = b""
23+
maximum_response_bytes: int = 524_288
2324

2425

2526
@dataclass(frozen=True, slots=True)
@@ -32,6 +33,10 @@ class HttpResponse:
3233
class TransportError(RuntimeError):
3334
"""Raised when the HTTP transport cannot complete a request."""
3435

36+
def __init__(self, message: str, *, code: str | None = None) -> None:
37+
super().__init__(message)
38+
self.code = code
39+
3540

3641
class Transport(Protocol):
3742
async def send(self, request: HttpRequest) -> HttpResponse: ...
@@ -49,6 +54,8 @@ def __init__(
4954

5055
async def send(self, request: HttpRequest) -> HttpResponse:
5156
try:
57+
if request.maximum_response_bytes <= 0:
58+
raise ValueError("maximum_response_bytes must be positive")
5259
target, hostname, host_header = await _pinned_target(
5360
request.url, self._allow_local_network
5461
)
@@ -78,14 +85,36 @@ async def send(self, request: HttpRequest) -> HttpResponse:
7885
content=request.body,
7986
extensions={"sni_hostname": hostname},
8087
)
81-
response = await client.send(outgoing, follow_redirects=False)
88+
response = await client.send(outgoing, follow_redirects=False, stream=True)
89+
try:
90+
body = bytearray()
91+
if request.method.upper() != "HEAD" and not 300 <= response.status_code < 400:
92+
success = 200 <= response.status_code < 300
93+
maximum = (
94+
request.maximum_response_bytes
95+
if success
96+
else min(request.maximum_response_bytes, 16_384)
97+
)
98+
async for chunk in response.aiter_bytes():
99+
if len(chunk) > maximum - len(body):
100+
if success:
101+
raise TransportError(
102+
"HTTP response exceeds its byte limit",
103+
code="RESPONSE_LIMIT_EXCEEDED",
104+
)
105+
# Preserve the HTTP failure without exposing truncated error JSON.
106+
body.clear()
107+
break
108+
body.extend(chunk)
109+
return HttpResponse(
110+
status=response.status_code,
111+
headers={name.lower(): value for name, value in response.headers.items()},
112+
body=bytes(body),
113+
)
114+
finally:
115+
await response.aclose()
82116
except (httpx.HTTPError, OSError, ValueError) as error:
83117
raise TransportError(f"HTTP transport failed: {error}") from error
84-
return HttpResponse(
85-
status=response.status_code,
86-
headers={name.lower(): value for name, value in response.headers.items()},
87-
body=response.content,
88-
)
89118

90119
async def aclose(self) -> None:
91120
if self._client is not None:

‎src/offering_protocol/service/service.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,11 @@ def _problem(
555555

556556

557557
def _encode(value: object) -> bytes:
558+
if isinstance(value, Page):
559+
document = value.model_dump(mode="json", by_alias=True, exclude_unset=True)
560+
for item in document["items"]:
561+
item.pop("odp_version", None)
562+
return json.dumps(document, separators=(",", ":"), ensure_ascii=False).encode()
558563
if hasattr(value, "model_dump_json"):
559564
encoded = value.model_dump_json(by_alias=True, exclude_unset=True)
560565
return cast(str, encoded).encode()

‎tests/test_agent.py‎

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -276,15 +276,18 @@ async def test_builds_agent_friendly_offering_details_without_invoking_action()
276276
transport = QueueTransport(
277277
response(SERVICE_DOCUMENT),
278278
response(ACTION_OFFERING),
279-
response(schema, content_type="application/schema+json"),
280279
)
280+
supporting = QueueTransport(response(schema, content_type="application/schema+json"))
281281
details = await ServiceClient(
282-
"https://demo.inflowpay.ai", transport=transport
282+
"https://demo.inflowpay.ai", transport=transport, supporting_transport=supporting
283283
).get_offering_details("rubber-plant")
284284
assert details.actions[0].http is not None
285285
assert details.actions[0].http.url == "https://demo.inflowpay.ai/actions/purchase"
286286
assert details.attribute_schema is not None
287-
assert len(transport.requests) == 3
287+
assert len(transport.requests) == 2
288+
assert len(supporting.requests) == 1
289+
assert [request.maximum_response_bytes for request in transport.requests] == [65_536, 524_288]
290+
assert supporting.requests[0].maximum_response_bytes == 262_144
288291

289292

290293
DIRECTORY_PAGE = """{
@@ -447,6 +450,8 @@ async def test_resolves_http_action_request_schema() -> None:
447450
transport=QueueTransport(
448451
response(SERVICE_DOCUMENT),
449452
response(offering),
453+
),
454+
supporting_transport=QueueTransport(
450455
response(schema, content_type="application/schema+json"),
451456
response(schema, content_type="application/schema+json"),
452457
response(schema, content_type="application/schema+json"),
@@ -476,22 +481,26 @@ async def test_resolves_openapi_action_and_rejects_invalid_openapi() -> None:
476481
"openapi":"3.1.0","paths":{"/purchase":{"post":{
477482
"operationId":"purchasePlant","responses":{}}}}
478483
}"""
484+
supporting = QueueTransport(response(openapi, content_type="application/json"))
479485
client = ServiceClient(
480486
"https://demo.inflowpay.ai",
481487
transport=QueueTransport(
482488
response(document),
483489
response(offering),
484-
response(openapi, content_type="application/json"),
485490
),
491+
supporting_transport=supporting,
486492
)
487493
resolved = await client.resolve_action("plant", "purchase")
488494
assert resolved.operation == {"operationId": "purchasePlant", "responses": {}}
495+
assert supporting.requests[0].maximum_response_bytes == 1_048_576
489496

490497
invalid_client = ServiceClient(
491498
"https://demo.inflowpay.ai",
492499
transport=QueueTransport(
493500
response(document),
494501
response(offering),
502+
),
503+
supporting_transport=QueueTransport(
495504
response('{"openapi":"3.0.0"}', content_type="application/json"),
496505
),
497506
)
@@ -522,6 +531,8 @@ async def test_offering_details_report_unusable_actions_and_attributes() -> None
522531
transport=QueueTransport(
523532
response(SERVICE_DOCUMENT),
524533
response(offering),
534+
),
535+
supporting_transport=QueueTransport(
525536
response(schema, content_type="application/schema+json"),
526537
),
527538
)
@@ -564,7 +575,7 @@ async def test_supporting_document_security_and_cache_edges() -> None:
564575
revalidating = ServiceClient(
565576
"https://demo.inflowpay.ai",
566577
cache=cache,
567-
transport=QueueTransport(response(b"", status=304)),
578+
supporting_transport=QueueTransport(response(b"", status=304)),
568579
)
569580
assert await revalidating._supporting_json(
570581
"https://schemas.example/a", "schema", "application/json", {"application/json"}, 100
@@ -721,9 +732,8 @@ async def test_agent_remaining_traversal_and_supporting_document_edges() -> None
721732
async def test_action_resolution_boundaries(monkeypatch: pytest.MonkeyPatch) -> None:
722733
details = await ServiceClient(
723734
"https://demo.inflowpay.ai",
724-
transport=QueueTransport(
725-
response(SERVICE_DOCUMENT), response(ACTION_OFFERING), response(b"", status=500)
726-
),
735+
transport=QueueTransport(response(SERVICE_DOCUMENT), response(ACTION_OFFERING)),
736+
supporting_transport=QueueTransport(response(b"", status=500)),
727737
).get_offering_details("rubber-plant")
728738
assert details.attribute_schema is None
729739
assert details.issues[0].scope.value == "attribute_schema"
@@ -798,6 +808,8 @@ async def fake_details(client: ServiceClient, identifier: str) -> OfferingDetail
798808
)
799809
),
800810
response(offering),
811+
),
812+
supporting_transport=QueueTransport(
801813
response(duplicate, content_type="application/json"),
802814
),
803815
)

0 commit comments

Comments
 (0)