mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-19 16:17:56 +02:00
refactor(cli): inject the control plane transport and keep httpx base_url
This commit is contained in:
@@ -1414,11 +1414,11 @@ def _call_host_backend_with_optional_tenant(
|
||||
"Find your workspace ID in LangSmith under Settings > Workspaces.",
|
||||
fg="yellow",
|
||||
)
|
||||
client._client.headers["X-Tenant-ID"] = click.prompt("Workspace ID")
|
||||
client.set_tenant(click.prompt("Workspace ID"))
|
||||
prompted_for_tenant = True
|
||||
continue
|
||||
if err.status_code == 403 and "not enabled" in err.message.lower():
|
||||
smith_base = _smith_dashboard_base_url(client._base_url)
|
||||
smith_base = _smith_dashboard_base_url(client.base_url)
|
||||
raise HostBackendError(
|
||||
"LangSmith Deployment is not enabled for this organization. "
|
||||
f"Enable it at {smith_base}/host/deployments"
|
||||
|
||||
@@ -24,10 +24,11 @@ class HostBackendClient:
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
tenant_id: str | None = None,
|
||||
*,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
):
|
||||
if not base_url:
|
||||
raise click.UsageError("Host backend URL is required")
|
||||
transport = httpx.HTTPTransport(retries=3)
|
||||
headers: dict[str, str] = {
|
||||
"X-Api-Key": api_key,
|
||||
"Accept": "application/json",
|
||||
@@ -36,8 +37,9 @@ class HostBackendClient:
|
||||
headers["X-Tenant-ID"] = tenant_id
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._client = httpx.Client(
|
||||
base_url=self._base_url,
|
||||
headers=headers,
|
||||
transport=transport,
|
||||
transport=transport or httpx.HTTPTransport(retries=3),
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
@@ -45,6 +47,9 @@ class HostBackendClient:
|
||||
def base_url(self) -> str:
|
||||
return self._base_url
|
||||
|
||||
def set_tenant(self, tenant_id: str) -> None:
|
||||
self._client.headers["X-Tenant-ID"] = tenant_id
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
@@ -53,8 +58,7 @@ class HostBackendClient:
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
try:
|
||||
full_url = self._base_url + path
|
||||
resp = self._client.request(method, full_url, json=payload, params=params)
|
||||
resp = self._client.request(method, path, json=payload, params=params)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as err:
|
||||
detail = err.response.text or str(err.response.status_code)
|
||||
|
||||
@@ -6,94 +6,96 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import click.exceptions
|
||||
import httpx
|
||||
import pytest
|
||||
from click.testing import CliRunner, Result
|
||||
|
||||
import langgraph_cli.archive as archive_module
|
||||
import langgraph_cli.deploy as deploy_module
|
||||
from langgraph_cli.cli import cli
|
||||
from langgraph_cli.host_backend import HostBackendError
|
||||
from langgraph_cli.host_backend import HostBackendClient
|
||||
|
||||
CONTROL_PLANE_URL = "https://control-plane.example.com"
|
||||
REGISTRY_URL = "https://registry.example.com/team"
|
||||
PUSH_TOKEN = "push-token"
|
||||
PUSHED_IMAGE = "registry.example.com/team/my-app:latest"
|
||||
PUSHED_DIGEST = "registry.example.com/team/my-app@sha256:abc123"
|
||||
CREATED_DEPLOYMENT_ID = "dep-created"
|
||||
CREATED_ID = "dep-created"
|
||||
TRACKED_PACKAGES = ["langgraph:1.0.0"]
|
||||
SIGNED_UPLOAD_URL = "https://storage.example.com/signed"
|
||||
ARCHIVE = ("/tmp/src.tgz", 2048, "langgraph.json")
|
||||
OBJECT_PATH = "tarballs/src.tgz"
|
||||
PLATFORM_FORMAT = "{{.Os}}/{{.Architecture}}"
|
||||
DIGESTS_FORMAT = "{{json .RepoDigests}}"
|
||||
NOT_A_CLI_DEPLOYMENT = (
|
||||
"push token is only available for 'internal_docker' source deployments"
|
||||
)
|
||||
LIST_DEPLOYMENTS = "GET /v2/deployments"
|
||||
CREATE_DEPLOYMENT = "POST /v2/deployments"
|
||||
|
||||
|
||||
def _push_token(deployment_id: str) -> str:
|
||||
return f"POST /v2/deployments/{deployment_id}/push-token"
|
||||
|
||||
|
||||
def _upload_url(deployment_id: str) -> str:
|
||||
return f"POST /v2/deployments/{deployment_id}/upload-url"
|
||||
|
||||
|
||||
def _patch(deployment_id: str) -> str:
|
||||
return f"PATCH /v2/deployments/{deployment_id}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ControlPlaneDouble:
|
||||
timeline: list[str]
|
||||
existing_deployments: list[dict] = field(default_factory=list)
|
||||
push_token_error: HostBackendError | None = None
|
||||
payloads: dict[str, dict] = field(default_factory=dict)
|
||||
push_token_status: int = 200
|
||||
bodies: dict[str, dict] = field(default_factory=dict)
|
||||
|
||||
def record(self, method: str, **payload: object) -> None:
|
||||
self.timeline.append(method)
|
||||
self.payloads[method] = payload
|
||||
def handle(self, request: httpx.Request) -> httpx.Response:
|
||||
route = f"{request.method} {request.url.path}"
|
||||
self.timeline.append(route)
|
||||
if request.content:
|
||||
self.bodies[route] = json.loads(request.content)
|
||||
return self._respond(request.method, request.url.path)
|
||||
|
||||
def client_class(self) -> type:
|
||||
double = self
|
||||
def _respond(self, method: str, path: str) -> httpx.Response:
|
||||
if (method, path) == ("GET", "/v2/deployments"):
|
||||
return httpx.Response(200, json={"resources": self.existing_deployments})
|
||||
if (method, path) == ("POST", "/v2/deployments"):
|
||||
return httpx.Response(201, json={"id": CREATED_ID})
|
||||
if path.endswith("/push-token"):
|
||||
if self.push_token_status != 200:
|
||||
return httpx.Response(self.push_token_status, text=NOT_A_CLI_DEPLOYMENT)
|
||||
return httpx.Response(
|
||||
200, json={"token": PUSH_TOKEN, "registry_url": REGISTRY_URL}
|
||||
)
|
||||
if path.endswith("/upload-url"):
|
||||
return httpx.Response(
|
||||
200, json={"upload_url": SIGNED_UPLOAD_URL, "object_path": OBJECT_PATH}
|
||||
)
|
||||
if method == "PATCH":
|
||||
return httpx.Response(200, json={"tenant_id": "tenant-1"})
|
||||
if method == "GET":
|
||||
deployment_id = path.rsplit("/", 1)[-1]
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=next(
|
||||
d for d in self.existing_deployments if d["id"] == deployment_id
|
||||
),
|
||||
)
|
||||
raise AssertionError(f"unexpected control plane call: {method} {path}")
|
||||
|
||||
class FakeHostBackendClient:
|
||||
def __init__(
|
||||
self, host_url: str, api_key: str, tenant_id: str | None = None
|
||||
) -> None:
|
||||
self.base_url = host_url
|
||||
def client_factory(self) -> Callable[..., HostBackendClient]:
|
||||
transport = httpx.MockTransport(self.handle)
|
||||
|
||||
def list_deployments(self, name_contains: str = "") -> dict:
|
||||
double.record("list_deployments", name_contains=name_contains)
|
||||
return {"resources": double.existing_deployments}
|
||||
def make(
|
||||
host_url: str, api_key: str, tenant_id: str | None = None
|
||||
) -> HostBackendClient:
|
||||
return HostBackendClient(host_url, api_key, tenant_id, transport=transport)
|
||||
|
||||
def get_deployment(self, deployment_id: str) -> dict:
|
||||
double.record("get_deployment", deployment_id=deployment_id)
|
||||
return next(
|
||||
d for d in double.existing_deployments if d["id"] == deployment_id
|
||||
)
|
||||
|
||||
def create_deployment(self, **payload: object) -> dict:
|
||||
double.record("create_deployment", **payload)
|
||||
return {"id": CREATED_DEPLOYMENT_ID}
|
||||
|
||||
def request_push_token(self, deployment_id: str) -> dict:
|
||||
double.record("request_push_token", deployment_id=deployment_id)
|
||||
if double.push_token_error is not None:
|
||||
raise double.push_token_error
|
||||
return {"token": PUSH_TOKEN, "registry_url": REGISTRY_URL}
|
||||
|
||||
def update_deployment(
|
||||
self, deployment_id: str, image_uri: str, **payload: object
|
||||
) -> dict:
|
||||
double.record(
|
||||
"update_deployment",
|
||||
deployment_id=deployment_id,
|
||||
image_uri=image_uri,
|
||||
**payload,
|
||||
)
|
||||
return {"tenant_id": "tenant-1"}
|
||||
|
||||
def request_upload_url(self, deployment_id: str) -> dict:
|
||||
double.record("request_upload_url", deployment_id=deployment_id)
|
||||
return {"upload_url": SIGNED_UPLOAD_URL, "object_path": OBJECT_PATH}
|
||||
|
||||
def update_deployment_internal_source(
|
||||
self, deployment_id: str, **payload: object
|
||||
) -> dict:
|
||||
double.record(
|
||||
"update_deployment_internal_source",
|
||||
deployment_id=deployment_id,
|
||||
**payload,
|
||||
)
|
||||
return {"tenant_id": "tenant-1"}
|
||||
|
||||
return FakeHostBackendClient
|
||||
return make
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -136,7 +138,7 @@ class DockerDouble:
|
||||
self.builds.append(
|
||||
{
|
||||
"tag": tag,
|
||||
"docker_command": docker_command,
|
||||
"docker_command": tuple(docker_command or ("docker", "build")),
|
||||
"extra_flags": tuple(extra_flags),
|
||||
}
|
||||
)
|
||||
@@ -225,7 +227,7 @@ def deploy_project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> DeployPro
|
||||
monkeypatch.setattr(deploy_module, "_no_input", False)
|
||||
monkeypatch.setattr(deploy_module, "_emitter", None)
|
||||
monkeypatch.setattr(
|
||||
deploy_module, "HostBackendClient", control_plane.client_class()
|
||||
deploy_module, "HostBackendClient", control_plane.client_factory()
|
||||
)
|
||||
monkeypatch.setattr(deploy_module, "build_docker_image", docker.build_docker_image)
|
||||
monkeypatch.setattr(deploy_module, "subp_exec", docker.subp_exec)
|
||||
@@ -249,15 +251,15 @@ def test_first_local_deploy_creates_then_builds_pushes_and_updates_in_order(
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert deploy_project.timeline == [
|
||||
"list_deployments",
|
||||
"create_deployment",
|
||||
LIST_DEPLOYMENTS,
|
||||
CREATE_DEPLOYMENT,
|
||||
"docker build",
|
||||
"request_push_token",
|
||||
_push_token(CREATED_ID),
|
||||
"docker login",
|
||||
"docker tag",
|
||||
"docker push",
|
||||
"docker inspect-digest",
|
||||
"update_deployment",
|
||||
_patch(CREATED_ID),
|
||||
]
|
||||
assert "Deployment updated" in result.output
|
||||
|
||||
@@ -267,11 +269,11 @@ def test_first_local_deploy_creates_an_internal_docker_deployment(
|
||||
) -> None:
|
||||
deploy_project.run("--no-remote")
|
||||
|
||||
assert deploy_project.control_plane.payloads["create_deployment"] == {
|
||||
assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT] == {
|
||||
"name": "my-app",
|
||||
"deployment_type": "dev",
|
||||
"source": "internal_docker",
|
||||
"config_path": None,
|
||||
"source_config": {"deployment_type": "dev"},
|
||||
"source_revision_config": {},
|
||||
"secrets": [],
|
||||
}
|
||||
|
||||
@@ -285,14 +287,19 @@ def test_first_local_deploy_creates_an_internal_docker_deployment(
|
||||
("--platform", "linux/amd64", "--load", "--progress=quiet"),
|
||||
id="apple_silicon_cross_builds_for_linux_amd64",
|
||||
),
|
||||
pytest.param("x86_64", None, (), id="amd64_host_uses_plain_docker_build"),
|
||||
pytest.param(
|
||||
"x86_64",
|
||||
("docker", "build"),
|
||||
(),
|
||||
id="amd64_host_uses_plain_docker_build",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_local_build_targets_linux_amd64(
|
||||
deploy_project: DeployProject,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
machine: str,
|
||||
expected_command: tuple[str, ...] | None,
|
||||
expected_command: tuple[str, ...],
|
||||
expected_flags: tuple[str, ...],
|
||||
) -> None:
|
||||
monkeypatch.setattr(deploy_module.platform, "machine", lambda: machine)
|
||||
@@ -344,9 +351,9 @@ def test_local_deploy_records_the_pushed_digest_and_tracked_packages(
|
||||
) -> None:
|
||||
deploy_project.run("--no-remote")
|
||||
|
||||
assert deploy_project.control_plane.payloads["update_deployment"] == {
|
||||
"deployment_id": CREATED_DEPLOYMENT_ID,
|
||||
"image_uri": PUSHED_DIGEST,
|
||||
assert deploy_project.control_plane.bodies[_patch(CREATED_ID)] == {
|
||||
"revision_source": "internal_docker",
|
||||
"source_revision_config": {"image_uri": PUSHED_DIGEST},
|
||||
"secrets": [],
|
||||
"tracked_packages": TRACKED_PACKAGES,
|
||||
}
|
||||
@@ -402,7 +409,7 @@ def test_three_failed_pushes_abort_before_the_deployment_is_updated(
|
||||
result = deploy_project.run("--no-remote")
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "update_deployment" not in deploy_project.timeline
|
||||
assert _patch(CREATED_ID) not in deploy_project.timeline
|
||||
|
||||
|
||||
def test_existing_deployment_matched_by_exact_name_is_updated_not_created(
|
||||
@@ -415,11 +422,8 @@ def test_existing_deployment_matched_by_exact_name_is_updated_not_created(
|
||||
|
||||
deploy_project.run("--no-remote")
|
||||
|
||||
assert "create_deployment" not in deploy_project.timeline
|
||||
assert (
|
||||
deploy_project.control_plane.payloads["update_deployment"]["deployment_id"]
|
||||
== "dep-existing"
|
||||
)
|
||||
assert CREATE_DEPLOYMENT not in deploy_project.timeline
|
||||
assert _patch("dep-existing") in deploy_project.timeline
|
||||
|
||||
|
||||
def test_deployment_not_created_by_the_cli_gets_an_actionable_error(
|
||||
@@ -428,10 +432,7 @@ def test_deployment_not_created_by_the_cli_gets_an_actionable_error(
|
||||
deploy_project.control_plane.existing_deployments = [
|
||||
{"id": "dep-ui", "name": "my-app"}
|
||||
]
|
||||
deploy_project.control_plane.push_token_error = HostBackendError(
|
||||
"push token is only available for 'internal_docker' source deployments",
|
||||
status_code=400,
|
||||
)
|
||||
deploy_project.control_plane.push_token_status = 400
|
||||
|
||||
result = deploy_project.run("--no-remote")
|
||||
|
||||
@@ -447,26 +448,25 @@ def test_remote_build_creates_an_internal_source_deployment_and_uploads_the_arch
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert deploy_project.timeline == [
|
||||
"list_deployments",
|
||||
"create_deployment",
|
||||
LIST_DEPLOYMENTS,
|
||||
CREATE_DEPLOYMENT,
|
||||
"create_archive",
|
||||
"request_upload_url",
|
||||
_upload_url(CREATED_ID),
|
||||
"upload_archive",
|
||||
"update_deployment_internal_source",
|
||||
_patch(CREATED_ID),
|
||||
]
|
||||
assert deploy_project.control_plane.payloads["create_deployment"]["source"] == (
|
||||
assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT]["source"] == (
|
||||
"internal_source"
|
||||
)
|
||||
assert deploy_project.uploads == [(SIGNED_UPLOAD_URL, ARCHIVE[0], ARCHIVE[1])]
|
||||
assert deploy_project.control_plane.payloads[
|
||||
"update_deployment_internal_source"
|
||||
] == {
|
||||
"deployment_id": CREATED_DEPLOYMENT_ID,
|
||||
"source_tarball_path": OBJECT_PATH,
|
||||
"config_path": ARCHIVE[2],
|
||||
assert deploy_project.control_plane.bodies[_patch(CREATED_ID)] == {
|
||||
"revision_source": "internal_source",
|
||||
"source_revision_config": {
|
||||
"source_tarball_path": OBJECT_PATH,
|
||||
"langgraph_config_path": ARCHIVE[2],
|
||||
},
|
||||
"source_config": {"install_command": "yarn install"},
|
||||
"secrets": [],
|
||||
"install_command": "yarn install",
|
||||
"build_command": None,
|
||||
"tracked_packages": TRACKED_PACKAGES,
|
||||
}
|
||||
assert "Build triggered" in result.output
|
||||
|
||||
@@ -259,22 +259,18 @@ class TestEnvWithoutDeploymentName:
|
||||
|
||||
class TestCallHostBackendWithOptionalTenant:
|
||||
def _make_client(self, handler):
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com",
|
||||
"test-key",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
return c
|
||||
|
||||
def _make_eu_client(self, handler):
|
||||
c = HostBackendClient("https://eu.api.host.langchain.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://eu.api.host.langchain.com",
|
||||
c = HostBackendClient(
|
||||
"https://eu.api.host.langchain.com",
|
||||
"test-key",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
return c
|
||||
|
||||
@@ -334,7 +330,6 @@ class TestCallHostBackendWithOptionalTenant:
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "smith.langchain.com" in exc_info.value.message
|
||||
assert seen_tenant_ids == [None, "workspace-123"]
|
||||
assert client._client.headers["X-Tenant-ID"] == "workspace-123"
|
||||
|
||||
def test_other_403_re_raises_original(self):
|
||||
client = self._make_client(
|
||||
|
||||
@@ -13,12 +13,8 @@ def mock_transport():
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_transport):
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=mock_transport,
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "test-key", transport=mock_transport
|
||||
)
|
||||
return c
|
||||
|
||||
@@ -39,12 +35,8 @@ def test_request_sends_headers():
|
||||
assert req.headers["accept"] == "application/json"
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "test-key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
result = c._request("GET", "/test")
|
||||
assert result == {"ok": True}
|
||||
@@ -56,12 +48,8 @@ def test_request_sends_json_payload():
|
||||
assert req.content == b'{"key":"value"}'
|
||||
return httpx.Response(200, json={"created": True})
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "test-key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
result = c._request("POST", "/test", {"key": "value"})
|
||||
assert result == {"created": True}
|
||||
@@ -69,25 +57,13 @@ def test_request_sends_json_payload():
|
||||
|
||||
def test_request_empty_body_returns_none():
|
||||
transport = httpx.MockTransport(lambda req: httpx.Response(200, content=b""))
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=transport,
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
c = HostBackendClient("https://api.example.com", "test-key", transport=transport)
|
||||
assert c._request("DELETE", "/test") is None
|
||||
|
||||
|
||||
def test_request_http_error_raises():
|
||||
transport = httpx.MockTransport(lambda req: httpx.Response(404, text="not found"))
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=transport,
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
c = HostBackendClient("https://api.example.com", "test-key", transport=transport)
|
||||
with pytest.raises(HostBackendError, match="404"):
|
||||
c._request("GET", "/missing")
|
||||
|
||||
@@ -96,13 +72,7 @@ def test_request_invalid_json_raises():
|
||||
transport = httpx.MockTransport(
|
||||
lambda req: httpx.Response(200, content=b"not json")
|
||||
)
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=transport,
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
c = HostBackendClient("https://api.example.com", "test-key", transport=transport)
|
||||
with pytest.raises(HostBackendError, match="Failed to decode"):
|
||||
c._request("GET", "/bad-json")
|
||||
|
||||
@@ -111,12 +81,8 @@ def test_request_transport_error_raises():
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "test-key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
with pytest.raises(HostBackendError, match="connection refused"):
|
||||
c._request("GET", "/test")
|
||||
@@ -145,12 +111,8 @@ def test_list_deployments_sends_query_params():
|
||||
assert req.url.params["name_contains"] == "my app"
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "test-key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
result = c.list_deployments("my app")
|
||||
assert result == {"ok": True}
|
||||
@@ -214,34 +176,13 @@ def test_update_deployment_external_omits_tracked_packages_when_absent():
|
||||
assert "revision_source" not in body
|
||||
|
||||
|
||||
def test_request_builds_full_url_with_path_prefix():
|
||||
"""base_url with a path prefix (/api-host) must not be dropped when paths start with /."""
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
assert str(req.url) == "https://smith.example.com/api-host/v2/deployments"
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
c = HostBackendClient("https://smith.example.com/api-host", "key")
|
||||
c._client = httpx.Client(
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
result = c._request("GET", "/v2/deployments")
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def _capturing_client(captured: dict) -> HostBackendClient:
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = req.read()
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
return c
|
||||
|
||||
@@ -315,12 +256,8 @@ def test_get_deploy_logs_all_revisions():
|
||||
assert "/revisions/" not in str(req.url)
|
||||
return httpx.Response(200, json={"logs": [{"message": "running"}]})
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
result = c.get_deploy_logs("proj-1", {"limit": 10})
|
||||
assert result == {"logs": [{"message": "running"}]}
|
||||
@@ -331,12 +268,8 @@ def test_get_deploy_logs_specific_revision():
|
||||
assert "/v1/projects/proj-1/revisions/rev-2/deploy_logs" in str(req.url)
|
||||
return httpx.Response(200, json={"logs": []})
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
result = c.get_deploy_logs("proj-1", {"limit": 10}, revision_id="rev-2")
|
||||
assert result == {"logs": []}
|
||||
@@ -348,12 +281,8 @@ def _routing_client(seen: dict) -> HostBackendClient:
|
||||
seen["url"] = str(req.url)
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
c = HostBackendClient("https://api.example.com/prefix", "key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com/prefix",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com/prefix", "key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
return c
|
||||
|
||||
@@ -535,3 +464,24 @@ def test_request_targets_control_plane_route_under_base_url(call, method, route)
|
||||
method,
|
||||
f"https://api.example.com/prefix{route}",
|
||||
)
|
||||
|
||||
|
||||
def test_injected_transport_receives_requests_under_the_prefixed_base_url():
|
||||
seen: dict = {}
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(req.url)
|
||||
seen["api_key"] = req.headers["x-api-key"]
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
c = HostBackendClient(
|
||||
"https://smith.example.com/api-host",
|
||||
"key",
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
assert c.list_revisions("dep-1", limit=2) == {"ok": True}
|
||||
assert seen == {
|
||||
"url": "https://smith.example.com/api-host/v2/deployments/dep-1/revisions?limit=2",
|
||||
"api_key": "key",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user