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
Binary file modified .coverage
Binary file not shown.
113 changes: 112 additions & 1 deletion coverage.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" ?>
<coverage version="7.10.4" timestamp="1755783599582" lines-valid="103" lines-covered="103" line-rate="1" branches-valid="4" branches-covered="4" branch-rate="1" complexity="0">
<coverage version="7.10.4" timestamp="1755788392017" lines-valid="190" lines-covered="190" line-rate="1" branches-valid="20" branches-covered="20" branch-rate="1" complexity="0">
<!-- Generated by coverage.py: https://coverage.readthedocs.io/en/7.10.4 -->
<!-- Based on https://raw.githubusercontent.com/cobertura/web/master/htdocs/xml/coverage-04.dtd -->
<sources>
Expand Down Expand Up @@ -171,5 +171,116 @@
</class>
</classes>
</package>
<package name="http" line-rate="1" branch-rate="1" complexity="0">
<classes>
<class name="__init__.py" filename="http/__init__.py" complexity="0" line-rate="1" branch-rate="1">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="7" hits="1"/>
</lines>
</class>
<class name="logging.py" filename="http/logging.py" complexity="0" line-rate="1" branch-rate="1">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="6" hits="1"/>
<line number="8" hits="1"/>
<line number="11" hits="1"/>
<line number="18" hits="1"/>
<line number="19" hits="1"/>
<line number="20" hits="1"/>
<line number="21" hits="1"/>
<line number="24" hits="1"/>
<line number="32" hits="1"/>
<line number="33" hits="1"/>
</lines>
</class>
<class name="telemetry.py" filename="http/telemetry.py" complexity="0" line-rate="1" branch-rate="1">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="6" hits="1"/>
<line number="9" hits="1"/>
<line number="16" hits="1"/>
<line number="19" hits="1"/>
<line number="22" hits="1"/>
<line number="30" hits="1"/>
<line number="31" hits="1" branch="true" condition-coverage="100% (2/2)"/>
<line number="32" hits="1"/>
<line number="33" hits="1"/>
<line number="34" hits="1" branch="true" condition-coverage="100% (2/2)"/>
<line number="35" hits="1"/>
<line number="36" hits="1"/>
</lines>
</class>
<class name="transport.py" filename="http/transport.py" complexity="0" line-rate="1" branch-rate="1">
<methods/>
<lines>
<line number="3" hits="1"/>
<line number="5" hits="1"/>
<line number="6" hits="1"/>
<line number="8" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="15" hits="1"/>
<line number="16" hits="1"/>
<line number="19" hits="1"/>
<line number="22" hits="1"/>
<line number="29" hits="1"/>
<line number="30" hits="1"/>
<line number="41" hits="1"/>
<line number="43" hits="1"/>
<line number="45" hits="1"/>
<line number="63" hits="1"/>
<line number="64" hits="1" branch="true" condition-coverage="100% (2/2)"/>
<line number="65" hits="1"/>
<line number="66" hits="1"/>
<line number="67" hits="1"/>
<line number="68" hits="1"/>
<line number="69" hits="1"/>
<line number="70" hits="1"/>
<line number="71" hits="1" branch="true" condition-coverage="100% (2/2)"/>
<line number="72" hits="1"/>
<line number="73" hits="1"/>
<line number="74" hits="1"/>
<line number="75" hits="1"/>
<line number="76" hits="1" branch="true" condition-coverage="100% (2/2)"/>
<line number="77" hits="1"/>
<line number="78" hits="1"/>
<line number="79" hits="1"/>
<line number="80" hits="1" branch="true" condition-coverage="100% (2/2)"/>
<line number="84" hits="1"/>
<line number="85" hits="1"/>
<line number="90" hits="1"/>
<line number="91" hits="1"/>
<line number="92" hits="1"/>
<line number="93" hits="1"/>
<line number="95" hits="1"/>
<line number="96" hits="1"/>
<line number="107" hits="1" branch="true" condition-coverage="100% (2/2)"/>
<line number="108" hits="1"/>
<line number="109" hits="1"/>
<line number="110" hits="1"/>
<line number="111" hits="1"/>
<line number="112" hits="1"/>
<line number="113" hits="1"/>
<line number="122" hits="1"/>
<line number="123" hits="1"/>
<line number="124" hits="1"/>
<line number="125" hits="1"/>
<line number="126" hits="1"/>
<line number="127" hits="1" branch="true" condition-coverage="100% (2/2)"/>
<line number="128" hits="1"/>
<line number="131" hits="1"/>
</lines>
</class>
</classes>
</package>
</packages>
</coverage>
3 changes: 2 additions & 1 deletion gavaconnect/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""Authentication module for GavaConnect SDK."""

from .basic import BasicAuthPolicy, BasicCredentials
from .bearer import BearerAuthPolicy, TokenProvider
from .bearer import AuthPolicy, BearerAuthPolicy, TokenProvider
from .providers import ClientCredentialsProvider

__all__ = [
"AuthPolicy",
"BasicAuthPolicy",
"BasicCredentials",
"BearerAuthPolicy",
Expand Down
13 changes: 13 additions & 0 deletions gavaconnect/http/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""HTTP transport layer for the GavaConnect SDK."""

from .logging import log_request, log_response
from .telemetry import otel_request_span, otel_response_span
from .transport import AsyncTransport

__all__ = [
"log_request",
"log_response",
"otel_request_span",
"otel_response_span",
"AsyncTransport",
]
35 changes: 35 additions & 0 deletions gavaconnect/http/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""HTTP request and response logging utilities."""

import logging
import time

import httpx

logger = logging.getLogger("gavaconnect")


async def log_request(req: httpx.Request) -> None:
"""Log an HTTP request with sanitized headers.

Args:
req: The HTTP request to log.

"""
req.extensions["start_time"] = time.perf_counter()
hdrs = dict(req.headers)
hdrs.pop("authorization", None)
logger.debug(f"HTTP {req.method} {req.url} headers={hdrs}")


async def log_response(req: httpx.Request, resp: httpx.Response) -> None:
"""Log an HTTP response with timing information.

Args:
req: The HTTP request.
resp: The HTTP response to log.

"""
dur = time.perf_counter() - req.extensions.get("start_time", time.perf_counter())
logger.info(
f"HTTP {req.method} {req.url} -> {resp.status_code} in {dur:.3f}s request_id={resp.headers.get('x-request-id')}"
)
36 changes: 36 additions & 0 deletions gavaconnect/http/telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""OpenTelemetry tracing utilities for HTTP requests."""

import httpx
from opentelemetry import trace

tracer = trace.get_tracer("gavaconnect")


async def otel_request_span(req: httpx.Request) -> None:
"""Start an OpenTelemetry span for an HTTP request.

Args:
req: The HTTP request to trace.

"""
span = tracer.start_span(
"http.client", attributes={"http.method": req.method, "http.url": str(req.url)}
)
req.extensions["otel_span"] = span


async def otel_response_span(req: httpx.Request, resp: httpx.Response) -> None:
"""Complete an OpenTelemetry span for an HTTP response.

Args:
req: The HTTP request.
resp: The HTTP response.

"""
span = req.extensions.pop("otel_span", None)
if span:
span.set_attribute("http.status_code", resp.status_code)
rid = resp.headers.get("x-request-id")
if rid:
span.set_attribute("http.response.request_id", rid)
span.end()
138 changes: 138 additions & 0 deletions gavaconnect/http/transport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""HTTP transport implementation with retry logic and error handling."""

from __future__ import annotations

import asyncio
import json
import random
from typing import Any

import httpx

from gavaconnect.auth import AuthPolicy
from gavaconnect.config import SDKConfig
from gavaconnect.errors import APIError, RateLimitError, TransportError


def _jitter(base: float, attempt: int) -> float:
return float(base * (2 ** (attempt - 1)) * (1 + random.random() * 0.2)) # nosec B311


class AsyncTransport:
"""Async HTTP transport with retry logic and authentication support."""

def __init__(self, cfg: SDKConfig) -> None:
"""Initialize the async transport.

Args:
cfg: SDK configuration containing timeout and retry settings.

"""
self.cfg = cfg
self.client = httpx.AsyncClient(
base_url=cfg.base_url,
http2=True,
timeout=httpx.Timeout(
cfg.total_timeout_s,
read=cfg.read_timeout_s,
connect=cfg.connect_timeout_s,
),
headers={"user-agent": cfg.user_agent, "x-client-version": cfg.user_agent},
)

async def close(self) -> None:
"""Close the underlying HTTP client."""
await self.client.aclose()

async def request(
self,
method: str,
url: str,
*,
auth: AuthPolicy | None = None,
**kw: Any, # noqa: ANN401
) -> httpx.Response:
"""Make an HTTP request with retry logic and authentication.

Args:
method: HTTP method (GET, POST, etc.).
url: Request URL.
auth: Optional authentication policy.
**kw: Additional keyword arguments for the request.

Returns:
The HTTP response.

Raises:
TransportError: If the request fails after all retries.

"""
req = self.client.build_request(method, url, **kw)
if auth:
await auth.authorize(req)
attempt = 1
while True:
try:
resp = await self.client.send(req, stream=False)
except httpx.HTTPError as e:
if attempt > self.cfg.retry.max_attempts:
raise TransportError(str(e)) from e
await asyncio.sleep(_jitter(self.cfg.retry.base_backoff_s, attempt))
attempt += 1
continue
if resp.status_code == 401 and auth and await auth.on_unauthorized():
req = self.client.build_request(method, url, **kw)
await auth.authorize(req)
resp = await self.client.send(req, stream=False)
if (
resp.status_code in self.cfg.retry.retry_on_status
and attempt <= self.cfg.retry.max_attempts
):
Comment thread
musale marked this conversation as resolved.
ra = resp.headers.get("retry-after")
backoff = (
float(ra)
if ra and ra.isdigit()
else _jitter(self.cfg.retry.base_backoff_s, attempt)
)
await asyncio.sleep(backoff)
attempt += 1
continue
return resp

@staticmethod
def raise_for_api_error(resp: httpx.Response) -> None:
"""Raise appropriate API error based on response status and content.

Args:
resp: HTTP response to check for errors.

Raises:
APIError: For general API errors.
RateLimitError: For rate limit errors (status 429).

"""
if resp.status_code < 400:
return
try:
b = resp.json()
err = b.get("error", {})
except (json.JSONDecodeError, ValueError) as e:
raise APIError(
resp.status_code,
"api_error",
resp.text,
None,
resp.headers.get("x-request-id"),
None,
resp.content,
) from e
type_ = err.get("type") or "api_error"
msg = err.get("message") or resp.text
code = err.get("code")
rid = resp.headers.get("x-request-id")
ra = err.get("retry_after")
if resp.status_code == 429:
raise RateLimitError(
resp.status_code, type_, msg, code, rid, ra, resp.content
)
raise APIError(resp.status_code, type_, msg, code, rid, ra, resp.content)
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,7 @@ dev = [
"pytest-cov>=6.2.1",
"pytest-asyncio>=0.25.0",
"respx>=0.22.0",
"opentelemetry-api>=1.36.0",
"opentelemetry-sdk>=1.36.0",
"h2>=4.2.0",
]
1 change: 1 addition & 0 deletions tests/test_auth_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def test_module_has_all_attribute(self):
assert isinstance(auth.__all__, list)

expected_exports = {
"AuthPolicy",
"BasicAuthPolicy",
"BasicCredentials",
"BearerAuthPolicy",
Expand Down
Loading
Loading