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
22 changes: 21 additions & 1 deletion porter_sandbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
ReadinessResponse,
SandboxDomainSpec,
SandboxEgressSpec,
SandboxMetricsPoint,
SandboxMetricsResponse,
SandboxMetricsResult,
SandboxMetricsSeries,
SandboxNetworkingSpec,
SandboxResourcesSpec,
SandboxSpec,
Expand All @@ -40,6 +44,7 @@
VolumeFileListResponse,
VolumeFileMoveRequest,
VolumeListResponse,
VolumeObjectSpec,
VolumeSpec,
)
from ._models import Volume as VolumeRecord
Expand All @@ -48,20 +53,25 @@
LogLineLevel,
SandboxDomainSpecVisibility,
SandboxesPhase,
SandboxMetric,
StatusResponsePhase,
VolumeFileEntryType,
VolumeObjectSpecAccess,
VolumePhase,
VolumeSpecType,
VolumeType,
)
from .healthz import AsyncHealthz, Healthz
from .porter import AsyncPorter, Porter
from .readyz import AsyncReadyz, Readyz
from .sandbox import AsyncSandbox, Sandbox
from .sandboxes import AsyncSandboxes, Sandboxes
from .volume import AsyncVolume, Volume, VolumeFile
from .volume import AsyncObjectVolume, AsyncVolume, ObjectVolume, Volume, VolumeFile
from .volumes import AsyncVolumes, Volumes

__all__ = [
"AsyncHealthz",
"AsyncObjectVolume",
"AsyncPorter",
"AsyncPorterSandboxApiClient",
"AsyncReadyz",
Expand Down Expand Up @@ -90,6 +100,7 @@
"LookupResult",
"MetricSummaryResponse",
"NotFoundError",
"ObjectVolume",
"Pagination",
"Porter",
"PorterSandboxApiClient",
Expand All @@ -101,6 +112,11 @@
"SandboxDomainSpecVisibility",
"SandboxEgressSpec",
"SandboxError",
"SandboxMetric",
"SandboxMetricsPoint",
"SandboxMetricsResponse",
"SandboxMetricsResult",
"SandboxMetricsSeries",
"SandboxNetworkingSpec",
"SandboxResourcesSpec",
"SandboxSpec",
Expand All @@ -117,8 +133,12 @@
"VolumeFileListResponse",
"VolumeFileMoveRequest",
"VolumeListResponse",
"VolumeObjectSpec",
"VolumeObjectSpecAccess",
"VolumePhase",
"VolumeRecord",
"VolumeSpec",
"VolumeSpecType",
"VolumeType",
"Volumes",
]
92 changes: 82 additions & 10 deletions porter_sandbox/_async_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,50 @@

from __future__ import annotations

from collections.abc import Mapping
from collections.abc import AsyncIterator, Mapping
from typing import Any

import httpx
from httpx._client import UseClientDefault

from ._base_client import _decode_body, _error_message, _request_headers
from ._binary import BinaryContent, _binary_content
from ._base_client import (
MAX_REDIRECTS,
_decode_body,
_error_message,
_origin,
_redirect_target,
_request_headers,
_rewind,
)
from ._binary import BinaryBody, BinaryContent, _binary_content
from ._config import Config
from ._errors import SandboxError, SandboxTimeoutError, error_for_status
from ._retries import DEFAULT_MAX_RETRIES, should_retry, sleep_for_attempt

USER_AGENT = "porter-sandbox-python/0.0.1"


# An async client cannot send a file object directly, so a stream body is read
# through this. The caller's file is rewound first, so each hop reads all of it.
_STREAM_CHUNK_BYTES = 1024 * 1024


def _as_async_stream(content: BinaryBody | None) -> Any:
"""Wrap a file object so an async client can send it, and pass bytes through."""
read = getattr(content, "read", None)
if read is None:
return content

async def chunks() -> AsyncIterator[bytes]:
while True:
chunk = read(_STREAM_CHUNK_BYTES)
if not chunk:
break
yield chunk

return chunks()


class _AsyncBaseClient:
"""Async HTTP transport shared by all generated async resource classes.

Expand All @@ -44,10 +73,9 @@ def __init__(
timeout=config.timeout,
verify=verify,
headers=headers,
# Some endpoints redirect to the service that owns the data. httpx
# does not follow redirects by default, so callers would get the
# 3xx instead of the response.
follow_redirects=True,
# _send_following_redirects follows them instead, so a stream
# body starts over on each hop.
follow_redirects=False,
)

async def __aenter__(self) -> _AsyncBaseClient:
Expand All @@ -59,14 +87,58 @@ async def __aexit__(self, *_exc: object) -> None:
async def aclose(self) -> None:
await self._http.aclose()

async def _send_following_redirects(
self,
*,
method: str,
url: str,
params: Mapping[str, Any] | None,
json: Any,
content: BinaryBody | None,
headers: Mapping[str, str],
timeout: float | None | UseClientDefault,
) -> httpx.Response:
"""Send one attempt, and follow any redirect it answers with.

The API answers a volume file write with a 307, which keeps the method
and the body. A hop that leaves the origin drops the credentials that
were meant for that origin.
"""
target: str | httpx.URL = url
hop_params = params

for _ in range(MAX_REDIRECTS + 1):
_rewind(content)
request = self._http.build_request(
method=method,
url=target,
params=hop_params,
json=json,
content=_as_async_stream(content),
headers=headers,
timeout=timeout,
)
if isinstance(target, httpx.URL) and _origin(target) != _origin(self._http.base_url):
request.headers.pop("Authorization", None)
response = await self._http.send(request)

redirect = _redirect_target(response)
if redirect is None:
return response
# The redirect target carries its own query already.
hop_params = None
target = redirect

raise SandboxError(f"Too many redirects for {url}")

async def _request(
self,
*,
method: str,
path: str,
params: Mapping[str, Any] | None = None,
json: Any = None,
content: bytes | None = None,
content: BinaryBody | None = None,
content_type: str | None = None,
headers: Mapping[str, str | None] | None = None,
timeout: float | None | UseClientDefault = httpx.USE_CLIENT_DEFAULT,
Expand Down Expand Up @@ -115,7 +187,7 @@ async def _send(
json: Any,
headers: Mapping[str, str | None] | None,
accept: str,
content: bytes | None = None,
content: BinaryBody | None = None,
content_type: str | None = None,
timeout: float | None | UseClientDefault = httpx.USE_CLIENT_DEFAULT,
retry: bool = True,
Expand All @@ -129,7 +201,7 @@ async def _send(

for attempt in range(max_retries + 1):
try:
response = await self._http.request(
response = await self._send_following_redirects(
method=method,
url=path,
params=params,
Expand Down
85 changes: 77 additions & 8 deletions porter_sandbox/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,40 @@
import httpx
from httpx._client import UseClientDefault

from ._binary import BinaryContent, _binary_content
from ._binary import BinaryBody, BinaryContent, _binary_content
from ._config import Config
from ._errors import SandboxError, SandboxTimeoutError, error_for_status
from ._retries import DEFAULT_MAX_RETRIES, should_retry, sleep_for_attempt_sync

USER_AGENT = "porter-sandbox-python/0.0.1"


# httpx cannot restart a stream body on its own redirect path, so the client
# follows redirects itself and starts the body over on each hop.
_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
MAX_REDIRECTS = 5


def _rewind(content: BinaryBody | None) -> None:
"""Put a stream body back to the start, so the next send reads all of it."""
seek = getattr(content, "seek", None)
if seek is not None:
seek(0)


def _origin(url: httpx.URL) -> tuple[str, str | None, int | None]:
"""The scheme, host and port a credential belongs to."""
return (url.scheme, url.host, url.port)


def _redirect_target(response: httpx.Response) -> httpx.URL | None:
"""The URL a response redirects to, or None when it does not redirect."""
if response.status_code not in _REDIRECT_STATUSES:
return None
location = response.headers.get("location")
return response.url.join(location) if location else None


def _decode_body(response: httpx.Response) -> Any:
if response.status_code == 204 or not response.content:
return None
Expand Down Expand Up @@ -75,10 +101,9 @@ def __init__(
timeout=config.timeout,
verify=verify,
headers=headers,
# Some endpoints redirect to the service that owns the data. httpx
# does not follow redirects by default, so callers would get the
# 3xx instead of the response.
follow_redirects=True,
# _send_following_redirects follows them instead, so a stream
# body starts over on each hop.
follow_redirects=False,
)

def __enter__(self) -> _BaseClient:
Expand All @@ -90,14 +115,58 @@ def __exit__(self, *_exc: object) -> None:
def close(self) -> None:
self._http.close()

def _send_following_redirects(
self,
*,
method: str,
url: str,
params: Mapping[str, Any] | None,
json: Any,
content: BinaryBody | None,
headers: Mapping[str, str],
timeout: float | None | UseClientDefault,
) -> httpx.Response:
"""Send one attempt, and follow any redirect it answers with.

The API answers a volume file write with a 307, which keeps the method
and the body. A hop that leaves the origin drops the credentials that
were meant for that origin.
"""
target: str | httpx.URL = url
hop_params = params

for _ in range(MAX_REDIRECTS + 1):
_rewind(content)
request = self._http.build_request(
method=method,
url=target,
params=hop_params,
json=json,
content=content,
headers=headers,
timeout=timeout,
)
if isinstance(target, httpx.URL) and _origin(target) != _origin(self._http.base_url):
request.headers.pop("Authorization", None)
response = self._http.send(request)

redirect = _redirect_target(response)
if redirect is None:
return response
# The redirect target carries its own query already.
hop_params = None
target = redirect

raise SandboxError(f"Too many redirects for {url}")

def _request(
self,
*,
method: str,
path: str,
params: Mapping[str, Any] | None = None,
json: Any = None,
content: bytes | None = None,
content: BinaryBody | None = None,
content_type: str | None = None,
headers: Mapping[str, str | None] | None = None,
timeout: float | None | UseClientDefault = httpx.USE_CLIENT_DEFAULT,
Expand Down Expand Up @@ -146,7 +215,7 @@ def _send(
json: Any,
headers: Mapping[str, str | None] | None,
accept: str,
content: bytes | None = None,
content: BinaryBody | None = None,
content_type: str | None = None,
timeout: float | None | UseClientDefault = httpx.USE_CLIENT_DEFAULT,
retry: bool = True,
Expand All @@ -160,7 +229,7 @@ def _send(

for attempt in range(max_retries + 1):
try:
response = self._http.request(
response = self._send_following_redirects(
method=method,
url=path,
params=params,
Expand Down
8 changes: 8 additions & 0 deletions porter_sandbox/_binary.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,17 @@
from dataclasses import dataclass
from datetime import datetime
from email.utils import parsedate_to_datetime
from typing import IO

import httpx

# The body a byte-bodied endpoint accepts.
#
# A retry or a redirect sends the body again, so a stream has to start over. A
# file object must therefore be seekable, which `open(path, "rb")` gives. Pass
# one to upload a large file without holding all of it in memory.
BinaryBody = bytes | IO[bytes]

# `bytes 0-1023/8192`. Absent on a whole-file response.
_CONTENT_RANGE_RE = re.compile(r"^bytes (\d+)-(\d+)/(\d+)$")

Expand Down
Loading
Loading