Skip to content
Merged
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
40 changes: 35 additions & 5 deletions packages/prime-tunnel/src/prime_tunnel/core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,17 @@
from prime_tunnel.models import TunnelInfo

# Retry configuration for transient connection errors
# Note: TimeoutException is NOT included because the request may have been processed
RETRYABLE_EXCEPTIONS = (
httpx.RemoteProtocolError,
httpx.ConnectError,
httpx.PoolTimeout,
)

# Idempotent requests (GET, DELETE) can also retry on timeouts since
# re-issuing the same request has no side effects
IDEMPOTENT_RETRYABLE_EXCEPTIONS = RETRYABLE_EXCEPTIONS + (httpx.TimeoutException,)


def _default_user_agent() -> str:
"""Build default User-Agent string."""
Expand Down Expand Up @@ -93,7 +98,32 @@ async def _request_with_retry(
json: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, str]] = None,
) -> httpx.Response:
"""Make async HTTP request with retry on transient connection errors."""
"""Make async HTTP request with retry on transient connection errors.

Used for non-idempotent requests (POST) where timeouts are NOT retried
because the server may have already processed the request.
"""
client = await self._get_client()
return await client.request(method, url, json=json, params=params)

@retry(
retry=retry_if_exception_type(IDEMPOTENT_RETRYABLE_EXCEPTIONS),
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=0.1, min=0.1, max=2),
reraise=True,
)
async def _idempotent_request_with_retry(
self,
method: str,
url: str,
json: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, str]] = None,
) -> httpx.Response:
"""Make async HTTP request with retry on transient errors including timeouts.

Used for idempotent requests (GET, DELETE) where retrying after a timeout
is safe because the operation has no additional side effects.
"""
client = await self._get_client()
return await client.request(method, url, json=json, params=params)

Expand Down Expand Up @@ -178,7 +208,7 @@ async def get_tunnel(self, tunnel_id: str) -> Optional[TunnelInfo]:
url = f"{self.base_url}/api/v1/tunnel/{tunnel_id}"

try:
response = await self._request_with_retry("GET", url)
response = await self._idempotent_request_with_retry("GET", url)
except httpx.TimeoutException as e:
raise TunnelTimeoutError(f"Request timed out: {e}") from e
except httpx.RequestError as e:
Expand Down Expand Up @@ -214,7 +244,7 @@ async def delete_tunnel(self, tunnel_id: str) -> bool:
url = f"{self.base_url}/api/v1/tunnel/{tunnel_id}"

try:
response = await self._request_with_retry("DELETE", url)
response = await self._idempotent_request_with_retry("DELETE", url)
Comment thread
rasdani marked this conversation as resolved.
except httpx.TimeoutException as e:
raise TunnelTimeoutError(f"Request timed out: {e}") from e
except httpx.RequestError as e:
Expand All @@ -234,7 +264,7 @@ async def bulk_delete_tunnels(self, tunnel_ids: list[str]) -> dict:
payload = {"tunnel_ids": tunnel_ids}

try:
response = await self._request_with_retry("DELETE", url, json=payload)
response = await self._idempotent_request_with_retry("DELETE", url, json=payload)
except httpx.TimeoutException as e:
raise TunnelTimeoutError(f"Request timed out: {e}") from e
except httpx.RequestError as e:
Expand All @@ -261,7 +291,7 @@ async def list_tunnels(self, team_id: Optional[str] = None) -> list[TunnelInfo]:
params = {"teamId": team_id} if team_id else None

try:
response = await self._request_with_retry("GET", url, params=params)
response = await self._idempotent_request_with_retry("GET", url, params=params)
except httpx.TimeoutException as e:
raise TunnelTimeoutError(f"Request timed out: {e}") from e
except httpx.RequestError as e:
Expand Down
Loading