From ab4879473a42fa83101c4d8e91c630cec2fe8c5b Mon Sep 17 00:00:00 2001 From: Yaw Boakye at Zebo Date: Sat, 12 Sep 2026 00:36:21 +0100 Subject: [PATCH 1/3] python: add response metadata envelopes --- src/inttegro/__init__.py | 2 + src/inttegro/async_http_client.py | 68 ++++++++++++++++++++++++-- src/inttegro/async_resources/orders.py | 6 +++ src/inttegro/http_client.py | 65 +++++++++++++++++++++++- src/inttegro/resources/orders.py | 12 +++++ src/inttegro/response.py | 31 ++++++++++++ tests/test_async_client.py | 32 ++++++++++++ tests/test_client.py | 32 ++++++++++++ 8 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 src/inttegro/response.py diff --git a/src/inttegro/__init__.py b/src/inttegro/__init__.py index 9e47108..0fe6b65 100644 --- a/src/inttegro/__init__.py +++ b/src/inttegro/__init__.py @@ -8,6 +8,7 @@ from .async_client import AsyncInttegroClient from .async_http_client import AsyncHTTPClient from .client import InttegroClient +from .response import InttegroResponse from .error_reporting import ( APIErrorReportContext, ErrorReport, @@ -67,6 +68,7 @@ "ErrorReportingPolicy", "HTTPReportContext", "InttegroClient", + "InttegroResponse", "InttegroError", "NetworkError", "RateLimitError", diff --git a/src/inttegro/async_http_client.py b/src/inttegro/async_http_client.py index 170ff38..b4c2822 100644 --- a/src/inttegro/async_http_client.py +++ b/src/inttegro/async_http_client.py @@ -9,7 +9,10 @@ import httpx from .error_reporting import ErrorReporter, ErrorReportingPolicy from .errors import NetworkError, TimeoutError +from ._model_base import ApiModel +from ._dynamic_value import DynamicValue from .http_client import HttpClient, RequestBody, generate_idempotency_key +from .response import InttegroResponse from ._telemetry import Telemetry from .version import VERSION @@ -92,8 +95,8 @@ def __init__( self.user_agent = self._codec.user_agent self.telemetry = self._codec.telemetry self.async_transport = transport - self._http_client = http_client or httpx.AsyncClient() - self._owns_http_client = http_client is None + self._http_client = http_client or (None if transport is not None else httpx.AsyncClient()) + self._owns_http_client = http_client is None and transport is None self._closed = False async def __aenter__(self) -> AsyncHttpClient: @@ -109,13 +112,16 @@ async def aclose(self) -> None: closed here. This makes it safe to share a configured connection pool. """ - if not self._closed and self._owns_http_client: + if not self._closed and self._owns_http_client and self._http_client is not None: await self._http_client.aclose() self._closed = True async def get(self, path: str, query: Optional[dict[str, Any]] = None) -> Any: return await self.request("GET", path, query=query) + async def get_with_response(self, path: str, query: Optional[dict[str, Any]] = None) -> InttegroResponse[Any]: + return await self.request_with_response("GET", path, query=query) + async def post( self, path: str, @@ -124,6 +130,44 @@ async def post( ) -> Any: return await self.request("POST", path, body=body, query=query) + async def post_with_response( + self, + path: str, + body: Optional[RequestBody] = None, + query: Optional[dict[str, Any]] = None, + ) -> InttegroResponse[Any]: + return await self.request_with_response("POST", path, body=body, query=query) + + async def post_resource_with_response( + self, + path: str, + field: str, + model_type: type[ApiModel], + body: Optional[RequestBody] = None, + query: Optional[dict[str, Any]] = None, + ) -> InttegroResponse[Any]: + response = await self.post_with_response(path, body=body, query=query) + data = response.data + if isinstance(data, model_type): + resource = data + elif isinstance(data, DynamicValue): + payload = data.to_dict() + value = payload.get(field) if isinstance(payload, dict) else None + if not isinstance(value, dict): + raise TypeError(f"Inttegro returned an invalid {field} response") + resource = model_type.from_dict(value) + else: + value = getattr(data, field, None) + if not isinstance(value, model_type): + raise TypeError(f"Inttegro returned an invalid {field} response") + resource = value + return InttegroResponse( + data=resource, + status=response.status, + headers=response.headers, + meta=response.meta, + ) + async def post_with_headers( self, path: str, @@ -210,6 +254,15 @@ async def request( body: Optional[RequestBody] = None, query: Optional[dict[str, Any]] = None, ) -> Any: + return (await self.request_with_response(method, path, body=body, query=query)).data + + async def request_with_response( + self, + method: str, + path: str, + body: Optional[RequestBody] = None, + query: Optional[dict[str, Any]] = None, + ) -> InttegroResponse[Any]: with self.telemetry.operation(path, method, self.base_url, VERSION) as span: url = self._build_url(path, query) encoded: RequestBody | dict[str, Any] | None = body @@ -230,7 +283,12 @@ async def request( self.telemetry.response(span, status, headers, decoded=False) result = self._parse_response(status, text_body, headers, path) self.telemetry.decoded(span) - return result + return InttegroResponse( + data=result, + status=status, + headers=headers, + meta=self._codec._response_meta(text_body), + ) def _json_request( self, @@ -257,6 +315,8 @@ async def _send_async(self, req: urllib.request.Request) -> tuple[int, dict[str, return status, {key.lower(): value for key, value in headers.items()}, body try: content = req.data if isinstance(req.data, bytes) else None + if self._http_client is None: + raise RuntimeError("Async HTTP transport is unavailable") response = await self._http_client.request( req.get_method(), req.full_url, diff --git a/src/inttegro/async_resources/orders.py b/src/inttegro/async_resources/orders.py index 1cf6b53..037a15b 100644 --- a/src/inttegro/async_resources/orders.py +++ b/src/inttegro/async_resources/orders.py @@ -4,6 +4,7 @@ from typing import TypeVar from .._model_base import ApiModel from ..async_http_client import AsyncHttpClient +from ..response import InttegroResponse from inttegro.order.order import Order from inttegro.order.page import Page from .._dynamic_value import DynamicValue @@ -142,6 +143,11 @@ async def create(self, payload: dict): """ return _resource(await self.http.post('/orders/create', payload), 'order', Order) + async def create_with_response(self, payload: dict) -> InttegroResponse[Order]: + """Create an order and keep HTTP response metadata with the decoded order.""" + response = await self.http.post_resource_with_response('/orders/create', 'order', Order, payload) + return InttegroResponse(data=response.data, status=response.status, headers=response.headers, meta=response.meta) + async def lookup(self, order_id: str, **options): """ Retrieve an existing order by ID. diff --git a/src/inttegro/http_client.py b/src/inttegro/http_client.py index 3485dcf..5a5f954 100644 --- a/src/inttegro/http_client.py +++ b/src/inttegro/http_client.py @@ -20,6 +20,7 @@ from ._dynamic_value import DynamicValue from ._telemetry import Telemetry from .error_reporting import ErrorReporter, ErrorReportingPolicy +from .response import InttegroResponse from .version import VERSION if TYPE_CHECKING: from opentelemetry.trace import TracerProvider @@ -67,6 +68,9 @@ def __init__( def get(self, path: str, query: Optional[dict[str, Any]] = None) -> Any: return self.request("GET", path, query=query) + def get_with_response(self, path: str, query: Optional[dict[str, Any]] = None) -> InttegroResponse[Any]: + return self.request_with_response("GET", path, query=query) + def post( self, path: str, @@ -75,6 +79,44 @@ def post( ) -> Any: return self.request("POST", path, body=body, query=query) + def post_with_response( + self, + path: str, + body: Optional[RequestBody] = None, + query: Optional[dict[str, Any]] = None, + ) -> InttegroResponse[Any]: + return self.request_with_response("POST", path, body=body, query=query) + + def post_resource_with_response( + self, + path: str, + field: str, + model_type: type[ApiModel], + body: Optional[RequestBody] = None, + query: Optional[dict[str, Any]] = None, + ) -> InttegroResponse[Any]: + response = self.post_with_response(path, body=body, query=query) + data = response.data + if isinstance(data, model_type): + resource = data + elif isinstance(data, DynamicValue): + payload = data.to_dict() + value = payload.get(field) if isinstance(payload, dict) else None + if not isinstance(value, dict): + raise TypeError(f"Inttegro returned an invalid {field} response") + resource = model_type.from_dict(value) + else: + value = getattr(data, field, None) + if not isinstance(value, model_type): + raise TypeError(f"Inttegro returned an invalid {field} response") + resource = value + return InttegroResponse( + data=resource, + status=response.status, + headers=response.headers, + meta=response.meta, + ) + def post_with_headers( self, path: str, @@ -172,6 +214,15 @@ def request( body: Optional[RequestBody] = None, query: Optional[dict[str, Any]] = None, ) -> Any: + return self.request_with_response(method, path, body=body, query=query).data + + def request_with_response( + self, + method: str, + path: str, + body: Optional[RequestBody] = None, + query: Optional[dict[str, Any]] = None, + ) -> InttegroResponse[Any]: with self.telemetry.operation(path, method, self.base_url, VERSION) as span: url = self._build_url(path, query) if body is not None: @@ -215,7 +266,12 @@ def request( self.telemetry.response(span, status, headers, decoded=False) result = self._parse_response(status, text_body, headers, path) self.telemetry.decoded(span) - return result + return InttegroResponse( + data=result, + status=status, + headers=headers, + meta=self._response_meta(text_body), + ) def _build_url(self, path: str, query: Optional[dict[str, Any]]) -> str: if path.startswith("http://") or path.startswith("https://"): @@ -327,6 +383,13 @@ def _parse_json(self, body: str) -> Any: except json.JSONDecodeError: return body + def _response_meta(self, body: str) -> dict[str, Any] | None: + parsed = self._parse_json(body) + if not isinstance(parsed, dict): + return None + meta = parsed.get("response_meta") + return meta if isinstance(meta, dict) else None + def _handle_error( self, status: int, diff --git a/src/inttegro/resources/orders.py b/src/inttegro/resources/orders.py index 0d32d26..fac9c61 100644 --- a/src/inttegro/resources/orders.py +++ b/src/inttegro/resources/orders.py @@ -6,6 +6,7 @@ from .._model_base import ApiModel from ..http_client import HttpClient +from ..response import InttegroResponse from inttegro.order.order import Order from inttegro.order.page import Page from .._dynamic_value import DynamicValue @@ -149,6 +150,17 @@ def create(self, payload: dict): """ return _resource(self.http.post("/orders/create", payload), "order", Order) + def create_with_response(self, payload: dict) -> InttegroResponse[Order]: + """Create an order and keep HTTP response metadata with the decoded order.""" + + response = self.http.post_resource_with_response("/orders/create", "order", Order, payload) + return InttegroResponse( + data=response.data, + status=response.status, + headers=response.headers, + meta=response.meta, + ) + def lookup(self, order_id: str, **options): """ Retrieve an existing order by ID. diff --git a/src/inttegro/response.py b/src/inttegro/response.py new file mode 100644 index 0000000..16b81f2 --- /dev/null +++ b/src/inttegro/response.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from dataclasses import dataclass +from collections.abc import Mapping +from typing import Generic, TypeVar, Any + + +T = TypeVar("T") + + +@dataclass(frozen=True) +class InttegroResponse(Generic[T]): + """Decoded SDK value plus response-only HTTP metadata.""" + + data: T + status: int + headers: Mapping[str, str] + meta: Mapping[str, Any] | None = None + + @property + def request_id(self) -> str | None: + return _header(self.headers, "x-request-id") + + @property + def retry_after(self) -> str | None: + return _header(self.headers, "retry-after") + + +def _header(headers: Mapping[str, str], name: str) -> str | None: + value = next((value for key, value in headers.items() if key.lower() == name), None) + return value if isinstance(value, str) and value else None diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 3c71438..97d22b3 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -40,6 +40,38 @@ async def __call__(self, req, timeout): class AsyncClientTest(unittest.IsolatedAsyncioTestCase): + async def test_response_envelope_exposes_response_only_metadata(self) -> None: + async def transport(req, timeout): + del req, timeout + await asyncio.sleep(0) + return ( + 200, + { + "content-type": "application/json", + "x-request-id": "req_async_meta", + "retry-after": "12", + }, + json.dumps( + { + "order": ORDER_BODY, + "response_meta": { + "request_id": "req_async_meta", + "debug": {"provider_attempts": 1}, + }, + } + ), + ) + + async with AsyncInttegroClient(api_key="sk_test_async", transport=transport) as client: + response = await client.orders.create_with_response({"line_items": []}) + + self.assertIsInstance(response.data, Order) + self.assertEqual("or_async_123", response.data.id) + self.assertEqual(200, response.status) + self.assertEqual("req_async_meta", response.request_id) + self.assertEqual("12", response.retry_after) + self.assertEqual("req_async_meta", response.meta["request_id"]) + async def test_resource_request_is_awaitable_and_decodes_typed_models(self) -> None: recorder = AsyncTransportRecorder() async with AsyncInttegroClient(api_key="sk_test_async", transport=recorder) as client: diff --git a/tests/test_client.py b/tests/test_client.py index adafbaa..259f292 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -239,6 +239,38 @@ def read_openapi_paths(path: Path) -> list[str]: class InttegroClientTest(unittest.TestCase): + def test_response_envelope_exposes_response_only_metadata(self): + class ResponseTransport: + def __call__(self, req, timeout): + del req, timeout + return ( + 200, + { + "content-type": "application/json", + "x-request-id": "req_123", + "retry-after": "15", + }, + json.dumps( + { + "order": ORDER_BODY, + "response_meta": { + "request_id": "req_123", + "debug": {"provider_attempts": 1}, + }, + } + ), + ) + + client = InttegroClient(api_key="sk_test", transport=ResponseTransport()) + response = client.orders.create_with_response({"line_items": []}) + + self.assertIsInstance(response.data, Order) + self.assertEqual("or_123", response.data.id) + self.assertEqual(200, response.status) + self.assertEqual("req_123", response.request_id) + self.assertEqual("15", response.retry_after) + self.assertEqual("req_123", response.meta["request_id"]) + def test_telemetry_does_not_name_unknown_routes_from_resource_ids(self): operation, route, server_address = _request_details( "/orders/or_private_123", "https://api.inttegro.com", None From d6a9befc5d14ed9d19599cc453a35169aa4b5d45 Mon Sep 17 00:00:00 2001 From: Yaw Boakye at Zebo Date: Sat, 12 Sep 2026 12:48:37 +0100 Subject: [PATCH 2/3] release: prepare Python SDK 8.1.0 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- src/inttegro/version.py | 2 +- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc66769..4d698e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: inttegro/openapi - ref: 524fd1c9db29a844c7e439e87d5a9d958a2522d6 + ref: eba39c0d748252b2ad9e1e43ed56fede0cec35b8 path: openapi persist-credentials: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08e6a5d..34757c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: inttegro/openapi - ref: 524fd1c9db29a844c7e439e87d5a9d958a2522d6 + ref: eba39c0d748252b2ad9e1e43ed56fede0cec35b8 path: openapi persist-credentials: false diff --git a/CHANGELOG.md b/CHANGELOG.md index db4199a..b07473f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## [Unreleased] +## [8.1.0] - 2026-09-12 + +- Added opt-in response envelopes that expose status, headers, request IDs, + retry hints, and response metadata without changing existing resource return + values. + ## [8.0.0] - 2026-09-11 - Breaking: moved resource models and enums from the `inttegro` root into singular resource packages such as `inttegro.payment.Payment`, with one public type per file. diff --git a/pyproject.toml b/pyproject.toml index 44e1f66..a734c13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "inttegro" -version = "8.0.0" +version = "8.1.0" description = "Official Python SDK for the Inttegro API" authors = ["Inttegro Engineering "] license = "MIT" diff --git a/src/inttegro/version.py b/src/inttegro/version.py index 35ba86e..3bbed43 100644 --- a/src/inttegro/version.py +++ b/src/inttegro/version.py @@ -1 +1 @@ -VERSION = "8.0.0" +VERSION = "8.1.0" From b8b8e1ccd1a0530212f43bc7d951c2197613c1de Mon Sep 17 00:00:00 2001 From: Yaw Boakye at Zebo Date: Sat, 12 Sep 2026 12:53:44 +0100 Subject: [PATCH 3/3] python: type error helpers as no-return --- src/inttegro/async_http_client.py | 6 +++--- src/inttegro/http_client.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/inttegro/async_http_client.py b/src/inttegro/async_http_client.py index b4c2822..8eacb71 100644 --- a/src/inttegro/async_http_client.py +++ b/src/inttegro/async_http_client.py @@ -4,7 +4,7 @@ import urllib.request import uuid from collections.abc import Awaitable, Callable, Mapping -from typing import TYPE_CHECKING, Any, Dict, Optional, Protocol +from typing import TYPE_CHECKING, Any, Dict, NoReturn, Optional, Protocol import httpx from .error_reporting import ErrorReporter, ErrorReportingPolicy @@ -357,5 +357,5 @@ def _parse_response( ) -> Any: return self._codec._parse_response(status, body, headers, path) - def _handle_error(self, status: int, headers: dict[str, str], raw_body: str) -> Any: - return self._codec._handle_error(status, headers, raw_body) + def _handle_error(self, status: int, headers: dict[str, str], raw_body: str) -> NoReturn: + self._codec._handle_error(status, headers, raw_body) diff --git a/src/inttegro/http_client.py b/src/inttegro/http_client.py index 5a5f954..46f9698 100644 --- a/src/inttegro/http_client.py +++ b/src/inttegro/http_client.py @@ -11,7 +11,7 @@ import urllib.request from collections.abc import Mapping from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional +from typing import TYPE_CHECKING, Any, Callable, Dict, NoReturn, Optional from ._model_base import ApiModel, ModelDecodeError, decode_value from ._request_base import ApiRequest, encode_request_value @@ -396,7 +396,7 @@ def _handle_error( headers: dict[str, str], raw_body: str, parsed_body: Any | None = None, - ) -> DynamicValue: + ) -> NoReturn: data = parsed_body if parsed_body is not None else self._parse_json(raw_body) message = "HTTP {}".format(status) payload = data