Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <engineering@inttegro.com>"]
license = "MIT"
Expand Down
2 changes: 2 additions & 0 deletions src/inttegro/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -67,6 +68,7 @@
"ErrorReportingPolicy",
"HTTPReportContext",
"InttegroClient",
"InttegroResponse",
"InttegroError",
"NetworkError",
"RateLimitError",
Expand Down
74 changes: 67 additions & 7 deletions src/inttegro/async_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@
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
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

Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -297,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)
6 changes: 6 additions & 0 deletions src/inttegro/async_resources/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
69 changes: 66 additions & 3 deletions src/inttegro/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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://"):
Expand Down Expand Up @@ -327,13 +383,20 @@ 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,
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
Expand Down
12 changes: 12 additions & 0 deletions src/inttegro/resources/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions src/inttegro/response.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion src/inttegro/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION = "8.0.0"
VERSION = "8.1.0"
Loading
Loading