Repository: basetenlabs/truss
Pull request #1602: Prepare support py 3.13 for CLI. related BT-14455
Author: login: marius-baseten
Created: 2025-04-18T00:33:04Z | Merged: 2025-04-23T22:51:18Z

<!--
  What does this PR add, remove, and/or change?
-->
## :rocket: What
* Add capability to run integration tests with min/max supported version.
* Running *all* tests in min/max version on main after merge.
* Upgrade dev python version to 3.12.
* Use same version for CI as in `tool-versions`, closing a gap for drift.
* Use dynamic ports for docker integrations tests. This also simplifies chains docker deployment. And it removes all hardcoded URLs/Ports from tests.
* Refactor some of the retry/wait logic for docker containers and fix type annos.
* (above points are fall out from trying to run tests with `pytest-xdist`, because `pytest-split` does not work in python 3.13 - despite clean port isolation, xdist still doesn't work and gives weird errors).

<!--
  How was the change described above implemented?
-->
## :computer: How

<!--
  How have I ensured release and ongoing quality of this change?
-->
## :microscope: Testing



Diff:
diff --git a/.github/actions/setup-python/action.yml b/.github/actions/setup-python/action.yml
index 89a00779e..9f386daa5 100644
--- a/.github/actions/setup-python/action.yml
+++ b/.github/actions/setup-python/action.yml
@@ -1,20 +1,39 @@
 name: "Setup Python"
 description: "setups python, poetry and associated cache"
-
+inputs:
+  python-version:
+    description: Python version to setup
+    required: false
 
 runs:
   using: "composite"
   steps:
-    - uses: actions/setup-python@v5
+    - name: Determine Python version
+      id: determine-version
+      shell: bash
+      run: |
+        if [[ -n "${{ inputs.python-version }}" ]]; then
+          echo "version=${{ inputs.python-version }}" >> "$GITHUB_OUTPUT"
+        else
+          fallback_version=$(grep '^python ' .tool-versions | cut -d' ' -f2)
+          if [[ -z "$fallback_version" ]]; then
+            echo "Failed to extract python version from .tool-versions" >&2
+            exit 1
+          fi
+          echo "version=$fallback_version" >> "$GITHUB_OUTPUT"
+        fi
+
+    - name: Install Python
+      uses: actions/setup-python@v5
       with:
-        python-version: '3.9.21'
+        python-version: ${{ steps.determine-version.outputs.version }}
 
-    - name: Get full Python version
-      id: full-python-version
+    - name: Show Python version
       shell: bash
       run: echo "version=$(python -c 'import sys; print(\"-\".join(str(v) for v in sys.version_info))')" >> $GITHUB_ENV
 
-    - run: curl -sSL https://install.python-poetry.org | python3 -
+    - name: Install Poetry
+      run: curl -sSL https://install.python-poetry.org | python3 -
       shell: bash
 
     - name: Configure poetry
diff --git a/.github/scripts/get-python-version-range.py b/.github/scripts/get-python-version-range.py
new file mode 100644
index 000000000..e02fd9078
--- /dev/null
+++ b/.github/scripts/get-python-version-range.py
@@ -0,0 +1,23 @@
+import os
+import sys
+
+import tomlkit
+from packaging.specifiers import SpecifierSet
+from packaging.version import Version
+
+with open("pyproject.toml", "r") as f:
+    doc = tomlkit.load(f)
+
+constraint = doc["tool"]["poetry"]["dependencies"]["python"]  # type: ignore[index]
+specifier = SpecifierSet(str(constraint))
+
+candidates = [Version(f"3.{i}") for i in range(0, 20)]
+matching = sorted([v for v in candidates if v in specifier])
+
+if not matching:
+    sys.exit("::error ::No Python versions match the constraint!")
+
+output_path = os.environ["GITHUB_OUTPUT"]
+with open(output_path, "a") as f:
+    f.write(f"min_supported_python_version={matching[0]}\n")
+    f.write(f"max_supported_python_version={matching[-1]}\n")
diff --git a/.github/workflows/_integration_test_shared.yml b/.github/workflows/_integration_test_shared.yml
index d036a6b2c..697de6b39 100644
--- a/.github/workflows/_integration_test_shared.yml
+++ b/.github/workflows/_integration_test_shared.yml
@@ -2,52 +2,97 @@ name: Reusable Integration Tests
 
 on:
   workflow_call:
+    inputs:
+      run_only_integration:
+        type: boolean
+        required: false
+        default: true
 
 jobs:
+  determine-python-versions:
+    runs-on: ubuntu-22.04
+    outputs:
+      min_supported_python_version: ${{ steps.extract.outputs.min_supported_python_version }}
+      max_supported_python_version: ${{ steps.extract.outputs.max_supported_python_version }}
+    steps:
+      - uses: actions/checkout@v4
+      - name: Install dependencies for version extraction
+        run: pip install tomlkit packaging
+      - name: Extract Python Version Range
+        id: extract
+        run: |
+          python .github/scripts/get-python-version-range.py
+
   truss-integration-tests:
+    needs: determine-python-versions
     runs-on: ubuntu-22.04
     strategy:
       fail-fast: false
       matrix:
+        python_version: [
+          "${{ needs.determine-python-versions.outputs.min_supported_python_version }}",
+          "${{ needs.determine-python-versions.outputs.max_supported_python_version }}"
+        ]
         split_group: ["1", "2", "3", "4", "5"]
     steps:
       - uses: actions/checkout@v4
       - uses: ./.github/actions/setup-python/
+        with:
+          python-version: ${{ matrix.python_version }}
       - run: sudo mkdir -p /bptr && sudo chown $(whoami):$(whoami) /bptr
       - run: poetry install --with=dev,dev-server --extras=all
-      - run: |
-          poetry run pytest truss/tests \
-          --durations=0 -m 'integration' \
-          --junitxml=report-${{ matrix.split_group }}.xml \
-          --splits 5 --group ${{ matrix.split_group }} \
+      - name: Run Pytest
+        run: |
+          if [[ "${{ inputs.run_only_integration }}" == "true" ]]; then
+            test_marker="-m integration"
+          else
+            test_marker=""
+          fi
 
+          poetry run pytest truss/tests $test_marker \
+            --durations=20 \
+            --junitxml=report-${{ matrix.python_version }}-${{ matrix.split_group }}.xml \
+            --splits 5 \
+            --group ${{ matrix.split_group }} \
       - name: Publish Test Report
         uses: mikepenz/action-junit-report@v4
         if: always()
         with:
-          commit: ${{github.event.workflow_run.head_sha}}
-          report_paths: "report-${{ matrix.split_group }}.xml"
+          commit: ${{ github.event.workflow_run.head_sha }}
+          report_paths: "report-${{ matrix.python_version }}-${{ matrix.split_group }}.xml"
 
   chains-integration-tests:
+    needs: determine-python-versions
     runs-on: ubuntu-22.04
     strategy:
       fail-fast: false
       matrix:
-        split_group: ["1"]
+        python_version: [
+          "${{ needs.determine-python-versions.outputs.min_supported_python_version }}",
+          "${{ needs.determine-python-versions.outputs.max_supported_python_version }}"
+        ]
     steps:
       - uses: actions/checkout@v4
       - uses: ./.github/actions/setup-python/
+        with:
+          python-version: ${{ matrix.python_version }}
       - run: sudo mkdir -p /bptr && sudo chown $(whoami):$(whoami) /bptr
       - run: poetry install --with=dev,dev-server --extras=all
-      - run: |
-          poetry run pytest truss-chains/tests \
-          --durations=0 -m 'integration' \
-          --junitxml=report.xml \
-          -s --log-cli-level=INFO \
+      - name: Run Chains Tests
+        run: |
+          if [[ "${{ inputs.run_only_integration }}" == "true" ]]; then
+            test_marker="-m integration"
+          else
+            test_marker=""
+          fi
 
+          poetry run pytest truss-chains/tests \
+            --durations=20 $test_marker \
+            --junitxml=report-${{ matrix.python_version }}.xml \
+            -s --log-cli-level=INFO
       - name: Publish Test Report
         uses: mikepenz/action-junit-report@v4
         if: always()
         with:
-          commit: ${{github.event.workflow_run.head_sha}}
-          report_paths: "report.xml"
+          commit: ${{ github.event.workflow_run.head_sha }}
+          report_paths: "report-${{ matrix.python_version }}.xml"
diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
index b5c3f975f..0dc381922 100644
--- a/.github/workflows/integration-tests.yml
+++ b/.github/workflows/integration-tests.yml
@@ -61,3 +61,5 @@ jobs:
     needs: [detect-version-changed, build-and-push-truss-base-images-if-needed]
     if: ${{ !failure() && !cancelled() && (needs.build-and-push-truss-base-images-if-needed.result == 'success' || needs.build-and-push-truss-base-images-if-needed.result == 'skipped') }}
     uses: ./.github/workflows/_integration_test_shared.yml
+    with:
+      run_only_integration: true
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 8956ca639..0c620eb2a 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -60,10 +60,12 @@ jobs:
             --version-tag ${{ needs.detect-version-changed.outputs.new_base_image_version }} \
             --skip-login --push
 
-  integration-tests:
+  all-tests:
     needs: [detect-version-changed, build-and-push-truss-base-images-if-needed]
     if: ${{ !failure() && !cancelled() && (needs.build-and-push-truss-base-images-if-needed.result == 'success' || needs.build-and-push-truss-base-images-if-needed.result == 'skipped') }}
     uses: ./.github/workflows/_integration_test_shared.yml
+    with:
+      run_only_integration: false
 
   publish-rc-to-pypi:
     needs: [detect-version-changed]
diff --git a/.tool-versions b/.tool-versions
index a9d6fde30..90d7d6f04 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1,2 +1,2 @@
-python 3.9.21
+python 3.12.2
 poetry 2.0.0
diff --git a/poetry.lock b/poetry.lock
index 8b48acf7d..ff8f38308 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -4638,5 +4638,5 @@ all = []
 
 [metadata]
 lock-version = "2.1"
-python-versions = ">=3.9,<3.13"
-content-hash = "0f24788c7458bf96bf37b396bb082f6b5200aeb75f8af3385d2b28e1bd9e4ac0"
+python-versions = ">=3.9,<=3.12.2"
+content-hash = "9c21db5661c77e0b8e83b00b4ce6fe18099feceab2e29d57e33b42e4256e107e"
diff --git a/pyproject.toml b/pyproject.toml
index 075001fc5..375f35381 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -71,8 +71,7 @@ truss-docker-build-setup = "truss.contexts.docker_build_setup:docker_build_setup
 #  isolated.
 [tool.poetry.dependencies]
 # "base" dependencies.
-# "When using chains, 3.9 will be required at runtime, but other truss functionality works with 3.8.
-python = ">=3.9,<3.13"
+python = ">=3.9,<=3.12.2"
 huggingface_hub = ">=0.25.0"
 pydantic = ">=2.10.0"
 PyYAML = ">=6.0"
diff --git a/truss-chains/tests/test_e2e.py b/truss-chains/tests/test_e2e.py
index 9a7fd915f..2a84bde97 100644
--- a/truss-chains/tests/test_e2e.py
+++ b/truss-chains/tests/test_e2e.py
@@ -278,13 +278,9 @@ def test_traditional_truss():
         assert truss_handle.spec.config.resources.cpu == "4"
         assert truss_handle.spec.config.model_name == "OverridePassthroughModelName"
 
-        port = utils.get_free_port()
-        truss_handle.docker_run(local_port=port, detach=True, network="host")
+        _, urls = truss_handle.docker_run_for_test()
 
-        response = requests.post(
-            f"http://localhost:{port}/v1/models/model:predict",
-            json={"call_count_increment": 5},
-        )
+        response = requests.post(urls.predict_url, json={"call_count_increment": 5})
         assert response.status_code == 200
         assert response.json() == 5
 
diff --git a/truss-chains/truss_chains/deployment/deployment_client.py b/truss-chains/truss_chains/deployment/deployment_client.py
index ec1b62f65..1df7a7d8a 100644
--- a/truss-chains/truss_chains/deployment/deployment_client.py
+++ b/truss-chains/truss_chains/deployment/deployment_client.py
@@ -34,7 +34,7 @@
 from truss.truss_handle import truss_handle
 from truss.util import log_utils
 from truss.util import path as truss_path
-from truss_chains import framework, private_types, public_types, utils
+from truss_chains import framework, private_types, public_types
 from truss_chains.deployment import code_gen
 
 if TYPE_CHECKING:
@@ -243,9 +243,7 @@ def push_debug_docker(
 class DockerChainletService(b10_service.TrussService):
     """This service is for Chainlets (not for Chains)."""
 
-    def __init__(self, port: int, **kwargs):
-        remote_url = f"http://localhost:{port}"
-
+    def __init__(self, remote_url: str, **kwargs):
         super().__init__(remote_url, is_draft=False, **kwargs)
 
     def authenticate(self) -> Dict[str, str]:
@@ -279,18 +277,18 @@ def _push_service_docker(
     truss_dir: pathlib.Path,
     chainlet_display_name: str,
     options: private_types.PushOptionsLocalDocker,
-    port: int,
-) -> None:
+) -> str:
     th = truss_handle.TrussHandle(truss_dir)
     th.add_secret(public_types._BASETEN_API_SECRET_NAME, options.baseten_chain_api_key)
-    th.docker_run(
-        local_port=port,
+    container = th.docker_run(
+        local_port=None,
         detach=True,
         wait_for_server_ready=True,
         network="host",
         container_name_prefix=chainlet_display_name,
         disable_json_logging=True,
     )
+    return truss_handle.get_docker_urls(container).base_url
 
 
 class DockerChainService(ChainService):
@@ -331,29 +329,27 @@ def _create_docker_chain(
     chainlet_to_predict_url: Dict[str, Dict[str, str]] = {}
     chainlet_to_service: Dict[str, DockerChainletService] = {}
     for chainlet_artifact in chainlet_artifacts:
-        port = utils.get_free_port()
-        service = DockerChainletService(port)
-
-        docker_internal_url = service.predict_url.replace(
-            "localhost", "host.docker.internal"
-        )
-        chainlet_to_predict_url[chainlet_artifact.display_name] = {
-            "predict_url": docker_internal_url
-        }
-        chainlet_to_service[chainlet_artifact.name] = service
-
         local_config_handler.LocalConfigHandler.set_dynamic_config(
             private_types.DYNAMIC_CHAINLET_CONFIG_KEY,
             json.dumps(chainlet_to_predict_url),
         )
-
-        truss_dir = chainlet_artifact.truss_dir
         logging.info(
             f"Building Chainlet `{chainlet_artifact.display_name}` docker image."
         )
-        _push_service_docker(
-            truss_dir, chainlet_artifact.display_name, docker_options, port
+        base_url = _push_service_docker(
+            chainlet_artifact.truss_dir, chainlet_artifact.display_name, docker_options
+        )
+
+        service = DockerChainletService(base_url)
+
+        docker_internal_url = service.predict_url.replace(
+            "0.0.0.0", "host.docker.internal"
         )
+        chainlet_to_predict_url[chainlet_artifact.display_name] = {
+            "predict_url": docker_internal_url
+        }
+        chainlet_to_service[chainlet_artifact.name] = service
+
         logging.info(
             f"Pushed Chainlet `{chainlet_artifact.display_name}` as docker container."
         )
diff --git a/truss-chains/truss_chains/utils.py b/truss-chains/truss_chains/utils.py
index dc576feff..877f056c2 100644
--- a/truss-chains/truss_chains/utils.py
+++ b/truss-chains/truss_chains/utils.py
@@ -4,7 +4,6 @@
 import logging
 import os
 import random
-import socket
 from typing import Any, Iterable, Iterator, TypeVar, Union
 
 import pydantic
@@ -113,14 +112,6 @@ def expect_one(it: Iterable[T]) -> T:
     raise ValueError("Iterable has more than one element.")
 
 
-def get_free_port() -> int:
-    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
-        s.bind(("", 0))  # Bind to a free port provided by the host.
-        s.listen(1)  # Not necessary but included for completeness.
-        port = s.getsockname()[1]  # Retrieve the port number assigned.
-        return port
-
-
 ########################################################################################
 
 
diff --git a/truss/tests/contexts/image_builder/test_serving_image_builder.py b/truss/tests/contexts/image_builder/test_serving_image_builder.py
index f376bbe92..576051bbc 100644
--- a/truss/tests/contexts/image_builder/test_serving_image_builder.py
+++ b/truss/tests/contexts/image_builder/test_serving_image_builder.py
@@ -264,9 +264,7 @@ def test_test_truss_server_model_cache_v1(test_data_path):
         truss_dir = test_data_path / "test_truss_server_model_cache_v1"
         tr = TrussHandle(truss_dir)
 
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True, network="host"
-        )
+        container, _ = tr.docker_run_for_test()
         time.sleep(15)
         assert "Downloading model.safetensors:" not in container.logs()
 
@@ -277,9 +275,7 @@ def test_test_truss_server_model_cache_v2(test_data_path):
         truss_dir = test_data_path / "test_truss_server_model_cache_v2"
         tr = TrussHandle(truss_dir)
 
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True, network="host"
-        )
+        container, _ = tr.docker_run_for_test()
         time.sleep(15)
         assert container.logs()
 
diff --git a/truss/tests/templates/control/control/test_server.py b/truss/tests/templates/control/control/test_server.py
index e09e89b3d..c4fe1c615 100644
--- a/truss/tests/templates/control/control/test_server.py
+++ b/truss/tests/templates/control/control/test_server.py
@@ -1,4 +1,5 @@
 import os
+import socket
 import sys
 from contextlib import contextmanager
 from pathlib import Path
@@ -41,8 +42,22 @@ def truss_original_hash():
     return "1234"
 
 
+def _get_free_port() -> int:
+    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+        sock.bind(("", 0))
+        return sock.getsockname()[1]
+
+
+@pytest.fixture
+def ports():
+    return {
+        "control_server_port": _get_free_port(),
+        "inference_server_port": _get_free_port(),
+    }
+
+
 @pytest.fixture
-def app(truss_container_fs, truss_original_hash):
+def app(truss_container_fs, truss_original_hash, ports):
     with _env_var({"HASH_TRUSS": truss_original_hash}):
         inf_serv_home = truss_container_fs / "app"
         control_app = create_app(
@@ -50,8 +65,8 @@ def app(truss_container_fs, truss_original_hash):
                 "inference_server_home": inf_serv_home,
                 "inference_server_process_args": ["python", "main.py"],
                 "control_server_host": "*",
-                "control_server_port": 8081,
-                "inference_server_port": 8082,
+                "control_server_port": ports["control_server_port"],
+                "inference_server_port": ports["inference_server_port"],
                 "oversee_inference_server": False,
                 "pip_path": "pip",
             }
@@ -75,10 +90,10 @@ def anyio_backend(request):
 
 
 @pytest.fixture()
-async def client(app):
+async def client(app, ports):
     transport = httpx.ASGITransport(app=app)
     async with httpx.AsyncClient(
-        transport=transport, base_url="http://localhost:8080"
+        transport=transport, base_url=f"http://localhost:{ports['control_server_port']}"
     ) as async_client:
         yield async_client
 
diff --git a/truss/tests/test_control_truss_patching.py b/truss/tests/test_control_truss_patching.py
index 01e7e06b2..957293cb5 100644
--- a/truss/tests/test_control_truss_patching.py
+++ b/truss/tests/test_control_truss_patching.py
@@ -57,11 +57,11 @@ def predict(self, model_input):
 """
 
     with ensure_kill_all():
-        result = th.docker_predict([1], tag=tag, binary=binary)
+        result = th.docker_predict([1], tag=tag, binary=binary, local_port=None)
         assert result[0] == 1
         orig_num_truss_images = len(th.get_all_docker_images())
         update_model_code(custom_model_control, new_model_code)
-        result = th.docker_predict([1], tag=tag, binary=binary)
+        result = th.docker_predict([1], tag=tag, binary=binary, local_port=None)
         assert result[0] == 2
         assert orig_num_truss_images == current_num_docker_images(th)
 
@@ -84,10 +84,10 @@ def test_control_truss_empty_dir_patch(
     def predict_with_added_empty_directory():
         # Adding empty directory should work
         (custom_model_control / "model" / "dir").mkdir()
-        return th.docker_predict([1], tag=tag, binary=binary)
+        return th.docker_predict([1], tag=tag, binary=binary, local_port=None)
 
     with ensure_kill_all():
-        th.docker_predict([1], tag=tag, binary=binary)
+        th.docker_predict([1], tag=tag, binary=binary, local_port=None)
         orig_num_truss_images = len(th.get_all_docker_images())
 
         predict_with_added_empty_directory()
@@ -114,10 +114,10 @@ def predict_with_unpatchable_change():
         # Changes that are not expressible with patch should also work
         # Changes to data dir are not currently patch expressible
         (custom_model_control / "data" / "dummy").touch()
-        return th.docker_predict([1], tag=tag, binary=binary)
+        return th.docker_predict([1], tag=tag, binary=binary, local_port=None)
 
     with ensure_kill_all():
-        th.docker_predict([1], tag=tag, binary=binary)
+        th.docker_predict([1], tag=tag, binary=binary, local_port=None)
         orig_num_truss_images = len(th.get_all_docker_images())
 
         predict_with_unpatchable_change()
@@ -141,22 +141,22 @@ def test_control_truss_python_sys_req_patch(
 
     def predict_with_python_requirement_added(req: str):
         th.add_python_requirement(req)
-        return th.docker_predict([1], tag=tag, binary=binary)
+        return th.docker_predict([1], tag=tag, binary=binary, local_port=None)
 
     def predict_with_python_requirement_removed(req):
         th.remove_python_requirement(req)
-        return th.docker_predict([1], tag=tag, binary=binary)
+        return th.docker_predict([1], tag=tag, binary=binary, local_port=None)
 
     def predict_with_system_requirement_added(pkg):
         th.add_system_package(pkg)
-        return th.docker_predict([1], tag=tag, binary=binary)
+        return th.docker_predict([1], tag=tag, binary=binary, local_port=None)
 
     def predict_with_system_requirement_removed(pkg):
         th.remove_system_package(pkg)
-        return th.docker_predict([1], tag=tag, binary=binary)
+        return th.docker_predict([1], tag=tag, binary=binary, local_port=None)
 
     with ensure_kill_all():
-        th.docker_predict([1], tag=tag, binary=binary)
+        th.docker_predict([1], tag=tag, binary=binary, local_port=None)
         orig_num_truss_images = len(th.get_all_docker_images())
 
         container = th.get_running_serving_container_ignore_hash()
@@ -202,10 +202,10 @@ def predict_with_ignored_changes():
         model_pycache_path = custom_model_control / "model" / "__pycache__"
         model_pycache_path.mkdir()
         (model_pycache_path / "foo.pyc").touch()
-        return th.docker_predict([1], tag=tag, binary=binary)
+        return th.docker_predict([1], tag=tag, binary=binary, local_port=None)
 
     with ensure_kill_all():
-        th.docker_predict([1], tag=tag, binary=binary)
+        th.docker_predict([1], tag=tag, binary=binary, local_port=None)
         orig_num_truss_images = current_num_docker_images(th)
 
         predict_with_ignored_changes()
@@ -223,10 +223,10 @@ def predict_with_added_model_dir_file():
         code_file_dir.mkdir(parents=True)
         with (code_file_dir / "foo.bar").open("w") as model_code_file:
             model_code_file.write("foobar")
-        return th.docker_predict([1], build_dir=tmp_path, tag=tag)
+        return th.docker_predict([1], build_dir=tmp_path, tag=tag, local_port=None)
 
     with ensure_kill_all():
-        th.docker_predict([1], tag=tag)
+        th.docker_predict([1], tag=tag, local_port=None)
         orig_num_truss_images = len(th.get_all_docker_images())
 
         predict_with_added_model_dir_file()
@@ -243,15 +243,15 @@ def test_patch_data_dir(control_model_handle_tag_tuple):
     def predict_with_data_dir_change():
         path = custom_model_control / "data" / "dummy"
         path.touch()
-        th.docker_predict([1], tag=tag)
+        th.docker_predict([1], tag=tag, local_port=None)
         with path.open("w") as file:
             file.write("foobar")
-        th.docker_predict([1], tag=tag)
+        th.docker_predict([1], tag=tag, local_port=None)
         path.unlink()
-        return th.docker_predict([1], tag=tag)
+        return th.docker_predict([1], tag=tag, local_port=None)
 
     with ensure_kill_all():
-        th.docker_predict([1], tag=tag)
+        th.docker_predict([1], tag=tag, local_port=None)
         orig_num_truss_images = len(th.get_all_docker_images())
 
         predict_with_data_dir_change()
@@ -264,17 +264,17 @@ def test_patch_env_var(control_model_handle_tag_tuple):
 
     def predict_with_environment_variables_change():
         th.add_environment_variable("foo", "bar")
-        result = th.docker_predict([1], tag=tag)
+        result = th.docker_predict([1], tag=tag, local_port=None)
         assert result == "bar"
         th.add_environment_variable("foo", "bar2")
-        result = th.docker_predict([1], tag=tag)
+        result = th.docker_predict([1], tag=tag, local_port=None)
         assert result == "bar2"
         config_path = truss_dir / "config.yaml"
         with config_path.open("w") as file:
             file.write("{}")
 
         assert th._serving_hash() != th.truss_hash_on_serving_container()
-        result = th.docker_predict([1], tag=tag)
+        result = th.docker_predict([1], tag=tag, local_port=None)
         assert result == "No foo :("
 
     with ensure_kill_all():
@@ -286,7 +286,7 @@ def predict(self, model_input):
         return os.environ.get("foo", "No foo :(")
 """
         update_model_code(truss_dir, new_model_code)
-        result = th.docker_predict([1], tag=tag)
+        result = th.docker_predict([1], tag=tag, local_port=None)
         assert result == "No foo :("
         orig_num_truss_images = len(th.get_all_docker_images())
 
@@ -300,7 +300,7 @@ def test_patch_external_package_dirs(custom_model_with_external_package):
     tag = "test-docker-custom-model-control-external-package-tag:0.0.1"
     th.live_reload()
     with ensure_kill_all():
-        th.docker_predict([1], tag=tag)
+        th.docker_predict([1], tag=tag, local_port=None)
         orig_num_truss_images = len(th.get_all_docker_images())
         th.clear_external_packages()
         th.add_external_package("../ext_pkg_patched")
@@ -322,7 +322,7 @@ def predict(self, model_input):
         return [1 for i in model_input]
 """
         update_model_code(custom_model_with_external_package, new_model_code)
-        th.docker_predict([1], tag=tag)
+        th.docker_predict([1], tag=tag, local_port=None)
         assert orig_num_truss_images == current_num_docker_images(th)
         shadow_truss_path = (
             LocalConfigHandler.shadow_trusses_dir_path()
@@ -344,10 +344,10 @@ def test_patch_secrets(control_model_handle_tag_tuple):
 
     def predict_with_secrets():
         th.add_secret("foo", "bar")
-        return th.docker_predict([1], tag=tag)
+        return th.docker_predict([1], tag=tag, local_port=None)
 
     with ensure_kill_all():
-        th.docker_predict([1], tag=tag)
+        th.docker_predict([1], tag=tag, local_port=None)
         orig_num_truss_images = len(th.get_all_docker_images())
 
         predict_with_secrets()
@@ -363,7 +363,7 @@ def test_predict_with_external_data_change(
     th.live_reload()
     tag = "test-external-data-access-tag:0.0.1"
     with ensure_kill_all():
-        th.docker_predict([], tag=tag, network="host")
+        th.docker_predict([], tag=tag, network="host", local_port=None)
         orig_num_truss_images = len(th.get_all_docker_images())
         th.remove_all_external_data()
         assert th._serving_hash() != th.truss_hash_on_serving_container()
@@ -377,7 +377,7 @@ def predict(self, model_input):
         return None
 """
         update_model_code(truss_dir, new_model_code)
-        th.docker_predict([], tag=tag, network="host")
+        th.docker_predict([], tag=tag, network="host", local_port=None)
         content = "foobar"
         filename = "foobar.txt"
         (tmp_path / filename).write_text(content)
@@ -393,7 +393,7 @@ def predict(self, model_input):
         update_model_code(truss_dir, new_model_code)
         url = f"http://host.docker.internal:9089/{filename}"
         th.add_external_data_item(url, filename)
-        result = th.docker_predict([], tag=tag, network="host")
+        result = th.docker_predict([], tag=tag, network="host", local_port=None)
         assert result == content
 
         content = "patched content"
@@ -408,6 +408,6 @@ def predict(self, model_input):
             ]
         )
         th._update_config(external_data=new_external_data)
-        result = th.docker_predict([], tag=tag, network="host")
+        result = th.docker_predict([], tag=tag, network="host", local_port=None)
         assert orig_num_truss_images == current_num_docker_images(th)
         assert result == content
diff --git a/truss/tests/test_custom_server.py b/truss/tests/test_custom_server.py
index 8b02d3eb9..ac9d635d2 100644
--- a/truss/tests/test_custom_server.py
+++ b/truss/tests/test_custom_server.py
@@ -18,18 +18,13 @@ def test_custom_server_truss(test_data_path):
         LocalConfigHandler.set_secret("hf_access_token", "123")
         try:
             print("Starting container")
-            _ = tr.docker_run(
-                local_port=8090,
-                detach=True,
-                wait_for_server_ready=True,
-                model_server_stop_retry_override=stop_after_attempt(3),
+            _, urls = tr.docker_run_for_test(
+                model_server_stop_retry_override=stop_after_attempt(3)
             )
         except Exception as e:
             raise Exception(f"Failed to start container: {e}")
-        truss_server_addr = "http://localhost:8090"
-        full_url = f"{truss_server_addr}/v1/models/model:predict"
 
-        response = requests.post(full_url, json={})
+        response = requests.post(urls.predict_url, json={})
         assert response.status_code == 200
         assert response.json() == {
             "message": "Hello World",
diff --git a/truss/tests/test_data/test_streaming_truss_with_tracing/config.yaml b/truss/tests/test_data/test_streaming_truss_with_tracing/config.yaml
index 46551f00c..7af8a5d1c 100644
--- a/truss/tests/test_data/test_streaming_truss_with_tracing/config.yaml
+++ b/truss/tests/test_data/test_streaming_truss_with_tracing/config.yaml
@@ -40,10 +40,11 @@ runtime:
     restart_check_delay_seconds: null
     restart_threshold_seconds: null
     stop_traffic_threshold_seconds: null
-  transport:
-    kind: websocket
   predict_concurrency: 1
   streaming_read_timeout: 60
+  transport:
+    kind: websocket
+  truss_server_version_override: null
 secrets: {}
 spec_version: '2.0'
 system_packages: []
diff --git a/truss/tests/test_model_inference.py b/truss/tests/test_model_inference.py
index 95a2e24cd..41a6c01c8 100644
--- a/truss/tests/test_model_inference.py
+++ b/truss/tests/test_model_inference.py
@@ -27,15 +27,11 @@
 from truss.local.local_config_handler import LocalConfigHandler
 from truss.tests.helpers import create_truss
 from truss.tests.test_testing_utilities_for_other_tests import ensure_kill_all
-from truss.truss_handle.truss_handle import TrussHandle, wait_for_truss
+from truss.truss_handle.truss_handle import TrussHandle, get_docker_urls, wait_for_truss
 
 logger = logging.getLogger(__name__)
 
 DEFAULT_LOG_ERROR = "Internal Server Error"
-PREDICT_URL = "http://localhost:8090/v1/models/model:predict"
-COMPLETIONS_URL = "http://localhost:8090/v1/completions"
-CHAT_COMPLETIONS_URL = "http://localhost:8090/v1/chat/completions"
-WEBSOCKETS_URL = "ws://localhost:8090/v1/websocket"
 
 
 @pytest.fixture
@@ -121,8 +117,8 @@ def predict(self, data):
     config = f"python_version: {config_python_version}"
 
     with ensure_kill_all(), _temp_truss(model, config) as tr:
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
-        response = requests.post(PREDICT_URL, json={})
+        container, urls = tr.docker_run_for_test()
+        response = requests.post(urls.predict_url, json={})
         assert inspected_python_version == response.json()
 
 
@@ -140,9 +136,7 @@ def predict(self, model_input):
     """
     config = "model_name: init-environment-truss"
     with ensure_kill_all(), _temp_truss(model, config) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
         logs = container.logs()
         _assert_logs_contain(logs, message="Executing model.load()")
         _assert_logs_contain(logs, message="Loading truss model from file")
@@ -156,12 +150,12 @@ def test_model_load_failure_truss(test_data_path):
         truss_dir = test_data_path / "model_load_failure_test"
         tr = TrussHandle(truss_dir)
 
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=False)
+        _, urls = tr.docker_run_for_test(wait_for_server_ready=False)
 
         # Sleep a few seconds to get the server some time to  wake up
         time.sleep(10)
 
-        truss_server_addr = "http://localhost:8090"
+        truss_server_addr = urls.base_url
 
         def handle_request_exception(func):
             def wrapper(*args, **kwargs):
@@ -219,14 +213,14 @@ def test_concurrency_truss(test_data_path):
     with ensure_kill_all():
         truss_dir = test_data_path / "test_concurrency_truss"
         tr = TrussHandle(truss_dir)
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
         # Each request takes 2 seconds, for this thread, we allow
         # a concurrency of 2. This means the first two requests will
         # succeed within the 2 seconds, and the third will fail, since
         # it cannot start until the first two have completed.
         def make_request():
-            requests.post(PREDICT_URL, json={}, timeout=3)
+            requests.post(urls.predict_url, json={}, timeout=3)
 
         successful_thread_1 = _PropagatingThread(target=make_request)
         successful_thread_2 = _PropagatingThread(target=make_request)
@@ -249,11 +243,11 @@ def test_requirements_file_truss(test_data_path):
     with ensure_kill_all():
         truss_dir = test_data_path / "test_requirements_file_truss"
         tr = TrussHandle(truss_dir)
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
         time.sleep(3)  # Sleeping to allow the load to finish
 
         # The prediction imports torch which is specified in a requirements.txt and returns if GPU is available.
-        response = requests.post(PREDICT_URL, json={})
+        response = requests.post(urls.predict_url, json={})
         assert response.status_code == 200
         assert response.json() is False
 
@@ -264,9 +258,9 @@ def test_requirements_pydantic(test_data_path, pydantic_major_version):
     with ensure_kill_all():
         truss_dir = test_data_path / f"test_pyantic_v{pydantic_major_version}"
         tr = TrussHandle(truss_dir)
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={})
+        response = requests.post(urls.predict_url, json={})
         assert response.status_code == 200
         assert response.json() == '{\n    "foo": "bla",\n    "bar": 123\n}'
 
@@ -276,9 +270,9 @@ def test_async_truss(test_data_path):
     with ensure_kill_all():
         truss_dir = test_data_path / "test_async_truss"
         tr = TrussHandle(truss_dir)
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={})
+        response = requests.post(urls.predict_url, json={})
         assert response.json() == {
             "preprocess_value": "value",
             "postprocess_value": "value",
@@ -290,16 +284,19 @@ def test_async_streaming(test_data_path):
     with ensure_kill_all():
         truss_dir = test_data_path / "test_streaming_async_generator_truss"
         tr = TrussHandle(truss_dir)
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={}, stream=True)
+        response = requests.post(urls.predict_url, json={}, stream=True)
         assert response.headers.get("transfer-encoding") == "chunked"
         assert [
             byte_string.decode() for byte_string in list(response.iter_content())
         ] == ["0", "1", "2", "3", "4"]
 
         predict_non_stream_response = requests.post(
-            PREDICT_URL, json={}, stream=True, headers={"accept": "application/json"}
+            urls.predict_url,
+            json={},
+            stream=True,
+            headers={"accept": "application/json"},
         )
         assert "transfer-encoding" not in predict_non_stream_response.headers
         assert predict_non_stream_response.json() == "01234"
@@ -310,13 +307,10 @@ def test_async_streaming_timeout(test_data_path):
     with ensure_kill_all():
         truss_dir = test_data_path / "test_streaming_read_timeout"
         tr = TrussHandle(truss_dir)
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
-
+        container, urls = tr.docker_run_for_test()
         # ChunkedEncodingError is raised when the chunk does not get processed due to streaming read timeout
         with pytest.raises(requests.exceptions.ChunkedEncodingError):
-            response = requests.post(PREDICT_URL, json={}, stream=True)
+            response = requests.post(urls.predict_url, json={}, stream=True)
 
             for chunk in response.iter_content():
                 pass
@@ -335,12 +329,10 @@ def test_streaming_with_error_and_stacktrace(test_data_path):
     with ensure_kill_all():
         truss_dir = test_data_path / "test_streaming_truss_with_error"
         tr = TrussHandle(truss_dir)
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
 
         predict_error_response = requests.post(
-            PREDICT_URL, json={"throw_error": True}, stream=True, timeout=2
+            urls.predict_url, json={"throw_error": True}, stream=True, timeout=2
         )
 
         # In error cases, the response will return whatever the stream returned,
@@ -353,7 +345,7 @@ def test_streaming_with_error_and_stacktrace(test_data_path):
 
         # Test that we are able to continue to make requests successfully
         predict_non_error_response = requests.post(
-            PREDICT_URL, json={"throw_error": False}, stream=True, timeout=2
+            urls.predict_url, json={"throw_error": False}, stream=True, timeout=2
         )
 
         assert [
@@ -397,9 +389,9 @@ def predict(self, request):
 
     with ensure_kill_all(), _temp_truss(inspect.getsource(Model), config) as tr:
         LocalConfigHandler.set_secret("secret", "secret_value")
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={})
+        response = requests.post(urls.predict_url, json={})
 
         assert response.json() == "secret_value"
 
@@ -409,11 +401,9 @@ def predict(self, request):
         _temp_truss(inspect.getsource(Model), config_with_no_secret) as tr,
     ):
         LocalConfigHandler.set_secret("secret", "secret_value")
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={})
+        response = requests.post(urls.predict_url, json={})
         assert "error" in response.json()
         _assert_logs_contain_error(container.logs(), missing_secret_error_message)
         assert "Internal Server Error" in response.json()["error"]
@@ -423,11 +413,9 @@ def predict(self, request):
     # Case where the secret is not mounted
     with ensure_kill_all(), _temp_truss(inspect.getsource(Model), config) as tr:
         LocalConfigHandler.remove_secret("secret")
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={})
+        response = requests.post(urls.predict_url, json={})
         assert response.status_code == 500
         _assert_logs_contain_error(container.logs(), missing_secret_error_message)
         assert "Internal Server Error" in response.json()["error"]
@@ -459,11 +447,9 @@ def predict(self, request):
     """
 
     with ensure_kill_all(), _temp_truss(model) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={}, stream=True)
+        response = requests.post(urls.predict_url, json={}, stream=True)
         logging.info(response.content)
         _assert_logs_contain_error(
             container.logs(),
@@ -497,13 +483,13 @@ def predict(self, request):
     """
 
     with ensure_kill_all(), _temp_truss(model) as tr:
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
         def make_request(delay: int):
             # For streamed responses, requests does not start receiving content from server until
             # `iter_content` is called, so we must call this in order to get an actual timeout.
             time.sleep(delay)
-            response = requests.post(PREDICT_URL, json={}, stream=True)
+            response = requests.post(urls.predict_url, json={}, stream=True)
 
             assert response.status_code == 200
             assert response.content == b"0 modified1 modified"
@@ -552,11 +538,11 @@ def predict(self, request):
     """
 
     with ensure_kill_all(), _temp_truss(model) as tr:
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
         def make_request(delay: int):
             time.sleep(delay)
-            response = requests.post(PREDICT_URL, json={})
+            response = requests.post(urls.predict_url, json={})
             assert response.status_code == 200
             assert response.json() == ["0 modified", "1 modified"]
 
@@ -587,11 +573,9 @@ def predict(self, request):
     """
 
     with ensure_kill_all(), _temp_truss(model) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={})
+        response = requests.post(urls.predict_url, json={})
         assert response.status_code == 500
         assert "error" in response.json()
 
@@ -611,11 +595,9 @@ def predict(self, request):
     """
 
     with ensure_kill_all(), _temp_truss(model_preprocess_error) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={})
+        response = requests.post(urls.predict_url, json={})
         assert response.status_code == 500
         assert "error" in response.json()
 
@@ -634,11 +616,9 @@ def postprocess(self, response):
     """
 
     with ensure_kill_all(), _temp_truss(model_postprocess_error) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={})
+        response = requests.post(urls.predict_url, json={})
         assert response.status_code == 500
         assert "error" in response.json()
         _assert_logs_contain_error(container.logs(), "ValueError: error")
@@ -653,11 +633,9 @@ async def predict(self, request):
     """
 
     with ensure_kill_all(), _temp_truss(model_async) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={})
+        response = requests.post(urls.predict_url, json={})
         assert response.status_code == 500
         assert "error" in response.json()
 
@@ -680,11 +658,9 @@ def predict(self, request):
     """
 
     with ensure_kill_all(), _temp_truss(model) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={})
+        response = requests.post(urls.predict_url, json={})
         assert response.status_code == 500
         assert "error" in response.json()
         assert response.headers["x-baseten-error-source"] == "04"
@@ -706,11 +682,9 @@ def test_truss_with_error_stacktrace(test_data_path):
     with ensure_kill_all():
         truss_dir = test_data_path / "test_truss_with_error"
         tr = TrussHandle(truss_dir)
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={})
+        response = requests.post(urls.predict_url, json={})
         assert response.status_code == 500
         assert "error" in response.json()
 
@@ -741,9 +715,8 @@ def test_slow_truss(test_data_path):
         truss_dir = test_data_path / "server_conformance_test_truss"
         tr = TrussHandle(truss_dir)
 
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=False)
-
-        truss_server_addr = "http://localhost:8090"
+        _, urls = tr.docker_run_for_test(wait_for_server_ready=False)
+        truss_server_addr = urls.base_url
 
         def _test_liveness_probe(expected_code):
             live = requests.get(f"{truss_server_addr}/")
@@ -831,11 +804,9 @@ def predict(self, model_input):
         staging_env = {"name": "staging"}
         staging_env_str = json.dumps(staging_env)
         LocalConfigHandler.set_dynamic_config("environment", staging_env_str)
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
         assert "Executing model.load with environment: staging" in container.logs()
-        response = requests.post(PREDICT_URL, json={})
+        response = requests.post(urls.predict_url, json={})
         assert response.json() == "staging"
         assert response.status_code == 200
         container.execute(["bash", "-c", "rm -f /etc/b10_dynamic_config/environment"])
@@ -843,11 +814,9 @@ def predict(self, model_input):
     # Test a truss deployment with no associated environment
     config = "model_name: init-no-environment-truss"
     with ensure_kill_all(), _temp_truss(model, config) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
         assert "Executing model.load with environment: None" in container.logs()
-        response = requests.post(PREDICT_URL, json={})
+        response = requests.post(urls.predict_url, json={})
         assert response.json() is None
         assert response.status_code == 200
 
@@ -867,9 +836,7 @@ def predict(self, model_input):
             return model_input
     """
     with ensure_kill_all(), _temp_truss(model, "") as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
         # Mimic environment changing to beta
         beta_env = {"name": "beta"}
         beta_env_str = json.dumps(beta_env)
@@ -913,9 +880,7 @@ def predict(self, model_input):
         staging_env = {"name": "staging"}
         staging_env_str = json.dumps(staging_env)
         LocalConfigHandler.set_dynamic_config("environment", staging_env_str)
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
         # Don't need to wait here because we explicitly grab the environment from dynamic_config_resolver before calling user's load()
         assert (
             f"Executing model.setup_environment with environment: {staging_env}"
@@ -958,7 +923,7 @@ def predict(self, model_input):
     """
 
     with ensure_kill_all(), _temp_truss(model, config) as tr:
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
         assert tr.spec.config.runtime.health_checks.restart_check_delay_seconds == 100
         assert tr.spec.config.runtime.health_checks.restart_threshold_seconds == 1700
@@ -974,14 +939,14 @@ def predict(self, model_input):
     """
 
     with ensure_kill_all(), _temp_truss(model, config) as tr:
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
         assert tr.spec.config.runtime.health_checks.restart_check_delay_seconds == 1200
         assert tr.spec.config.runtime.health_checks.restart_threshold_seconds == 90
         assert tr.spec.config.runtime.health_checks.stop_traffic_threshold_seconds == 50
 
     with ensure_kill_all(), _temp_truss(model, "") as tr:
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
         assert tr.spec.config.runtime.health_checks.restart_check_delay_seconds is None
         assert tr.spec.config.runtime.health_checks.restart_threshold_seconds is None
@@ -1004,14 +969,10 @@ def predict(self, model_input):
             return model_input
     """
     with ensure_kill_all(), _temp_truss(model, "") as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=False
-        )
-
-        truss_server_addr = "http://localhost:8090"
+        container, urls = tr.docker_run_for_test(wait_for_server_ready=False)
         for _ in range(5):
             time.sleep(1)
-            healthy = requests.get(f"{truss_server_addr}/v1/models/model")
+            healthy = requests.get(f"{urls.base_url}/v1/models/model")
             if healthy.status_code == 503:
                 break
             assert healthy.status_code == 200
@@ -1029,9 +990,7 @@ def predict(self, model_input):
             return model_input
     """
     with ensure_kill_all(), _temp_truss(model, "") as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=False
-        )
+        container, urls = tr.docker_run_for_test(wait_for_server_ready=False)
         time.sleep(1)
         _assert_logs_contain_error(
             container.logs(),
@@ -1048,16 +1007,11 @@ def predict(self, model_input):
             return model_input
     """
     with ensure_kill_all(), _temp_truss(model, "") as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=False
-        )
+        container, urls = tr.docker_run_for_test(wait_for_server_ready=False)
 
         # Sleep a few seconds to get the server some time to wake up
         time.sleep(10)
-
-        truss_server_addr = "http://localhost:8090"
-
-        healthy = requests.get(f"{truss_server_addr}/v1/models/model")
+        healthy = requests.get(f"{urls.base_url}/v1/models/model")
         assert healthy.status_code == 503
         assert (
             "Exception while checking if model is healthy: not healthy"
@@ -1079,13 +1033,9 @@ def predict(self, model_input):
             return model_input
     """
     with ensure_kill_all(), _temp_truss(model, "") as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=False
-        )
-        truss_server_addr = "http://localhost:8090"
-
+        container, urls = tr.docker_run_for_test(wait_for_server_ready=False)
         time.sleep(5)
-        healthy = requests.get(f"{truss_server_addr}/v1/models/model")
+        healthy = requests.get(f"{urls.base_url}/v1/models/model")
         assert healthy.status_code == 503
         # Ensure we only log after model.load is complete
         assert "Health check failed." not in container.logs()
@@ -1093,10 +1043,10 @@ def predict(self, model_input):
         # Sleep a few seconds to get the server some time to wake up
         time.sleep(10)
 
-        healthy = requests.get(f"{truss_server_addr}/v1/models/model")
+        healthy = requests.get(f"{urls.base_url}/v1/models/model")
         assert healthy.status_code == 503
         assert container.logs().count("Health check failed.") == 1
-        healthy = requests.get(f"{truss_server_addr}/v1/models/model")
+        healthy = requests.get(f"{urls.base_url}/v1/models/model")
         assert healthy.status_code == 503
         assert container.logs().count("Health check failed.") == 2
 
@@ -1119,27 +1069,30 @@ def predict(self, model_input):
             return model_input
     """
     with ensure_kill_all(), _temp_truss(model, "") as tr:
-        tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=False)
+        container, urls = tr.docker_run_for_test(wait_for_server_ready=False)
         time.sleep(5)
-        truss_server_addr = "http://localhost:8090"
-        healthy = requests.get(f"{truss_server_addr}/v1/models/model")
+        healthy = requests.get(f"{urls.base_url}/v1/models/model")
         assert healthy.status_code == 503
         time.sleep(10)
-        healthy = requests.get(f"{truss_server_addr}/v1/models/model")
+        healthy = requests.get(f"{urls.base_url}/v1/models/model")
         assert healthy.status_code == 200
 
         healthy_responses = [True, "yessss", 34, {"woo": "hoo"}]
         for response in healthy_responses:
-            predict_response = requests.post(PREDICT_URL, json={"healthy": response})
+            predict_response = requests.post(
+                urls.predict_url, json={"healthy": response}
+            )
             assert predict_response.status_code == 200
-            healthy = requests.get(f"{truss_server_addr}/v1/models/model")
+            healthy = requests.get(f"{urls.base_url}/v1/models/model")
             assert healthy.status_code == 200
 
         not_healthy_responses = [False, "", 0, {}]
         for response in not_healthy_responses:
-            predict_response = requests.post(PREDICT_URL, json={"healthy": response})
+            predict_response = requests.post(
+                urls.predict_url, json={"healthy": response}
+            )
             assert predict_response.status_code == 200
-            healthy = requests.get(f"{truss_server_addr}/v1/models/model")
+            healthy = requests.get(f"{urls.base_url}/v1/models/model")
             assert healthy.status_code == 503
 
     model = """
@@ -1151,11 +1104,8 @@ def predict(self, model_input):
             return model_input
     """
     with ensure_kill_all(), _temp_truss(model, "") as tr:
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
-
-        truss_server_addr = "http://localhost:8090"
-
-        healthy = requests.get(f"{truss_server_addr}/v1/models/model")
+        container, urls = tr.docker_run_for_test()
+        healthy = requests.get(f"{urls.base_url}/v1/models/model")
         assert healthy.status_code == 200
 
 
@@ -1171,12 +1121,9 @@ def predict(self, model_input):
             return model_input
     """
     with ensure_kill_all(), _temp_truss(model, "") as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
-        metrics_url = "http://localhost:8090/metrics"
-        requests.post(PREDICT_URL, json={})
-        resp = requests.get(metrics_url)
+        container, urls = tr.docker_run_for_test()
+        requests.post(urls.predict_url, json={})
+        resp = requests.get(urls.metrics_url)
         assert resp.status_code == 200
         metric_names = [
             family.name for family in text_string_to_metric_families(resp.text)
@@ -1205,12 +1152,9 @@ def predict(self, model_input):
     - opentelemetry-exporter-prometheus>=0.52b0
     """
     with ensure_kill_all(), _temp_truss(model, config) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
-        metrics_url = "http://localhost:8090/metrics"
-        requests.post(PREDICT_URL, json={})
-        resp = requests.get(metrics_url)
+        _, urls = tr.docker_run_for_test()
+        requests.post(urls.predict_url, json={})
+        resp = requests.get(urls.metrics_url)
         assert resp.status_code == 200
         metric_names = {
             family.name for family in text_string_to_metric_families(resp.text)
@@ -1249,17 +1193,15 @@ def predict(self, request):
             print(f"Done {request}")
             return request
     """
+    with ensure_kill_all(), _temp_truss(model) as tr:
+        container, urls = tr.docker_run_for_test()
 
-    async def predict_request(data: dict):
-        async with httpx.AsyncClient() as client:
-            response = await client.post(PREDICT_URL, json=data)
-            response.raise_for_status()
-            return response.json()
+        async def predict_request(data: dict):
+            async with httpx.AsyncClient() as client:
+                response = await client.post(urls.predict_url, json=data)
+                response.raise_for_status()
+                return response.json()
 
-    with ensure_kill_all(), _temp_truss(model) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
         await predict_request({"seconds": 0, "task": 0})  # Warm up server.
 
         # Test starting two requests, each taking 2 seconds, then terminating server.
@@ -1285,12 +1227,21 @@ async def predict_request(data: dict):
         _patch_termination_timeout(container, 3, truss_container_fs)
         # Now only one request should complete.
         container.restart()
-        wait_for_truss("http://localhost:8090", container, True)
-        await predict_request({"seconds": 0, "task": 0})  # Warm up server.
+        del predict_request  # The restarted container has a different port.
+        new_urls = get_docker_urls(container)
+        wait_for_truss(container, True)
 
-        task_2 = asyncio.create_task(predict_request({"seconds": 2, "task": 2}))
+        async def new_predict_request(data: dict):
+            async with httpx.AsyncClient() as client:
+                response = await client.post(new_urls.predict_url, json=data)
+                response.raise_for_status()
+                return response.json()
+
+        await new_predict_request({"seconds": 0, "task": 0})  # Warm up server.
+
+        task_2 = asyncio.create_task(new_predict_request({"seconds": 2, "task": 2}))
         await asyncio.sleep(0.1)  # Yield to event loop to make above task run.
-        task_3 = asyncio.create_task(predict_request({"seconds": 2, "task": 3}))
+        task_3 = asyncio.create_task(new_predict_request({"seconds": 2, "task": 3}))
         await asyncio.sleep(0.1)  # Yield to event loop to make above task run.
         t0 = time.perf_counter()
         container.stop(10)
@@ -1342,28 +1293,26 @@ def test_streaming_truss_with_user_tracing(test_data_path, enable_tracing_data):
             )
         )
 
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
 
         # A request for which response is not completely read
         headers_0 = _make_otel_headers()
         predict_response = requests.post(
-            PREDICT_URL, json={}, stream=True, headers=headers_0
+            urls.predict_url, json={}, stream=True, headers=headers_0
         )
         # We just read the first part and leave it hanging here
         next(predict_response.iter_content())
 
         headers_1 = _make_otel_headers()
         predict_response = requests.post(
-            PREDICT_URL, json={}, stream=True, headers=headers_1
+            urls.predict_url, json={}, stream=True, headers=headers_1
         )
         assert predict_response.headers.get("transfer-encoding") == "chunked"
 
         # When accept is set to application/json, the response is not streamed.
         headers_2 = _make_otel_headers()
         predict_non_stream_response = requests.post(
-            PREDICT_URL,
+            urls.predict_url,
             json={},
             stream=True,
             headers={**headers_2, "accept": "application/json"},
@@ -1423,15 +1372,17 @@ def predict(self, inputs):
     from fastapi import status
 
     with ensure_kill_all(), _temp_truss(model) as tr:
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={"code": status.HTTP_204_NO_CONTENT})
+        response = requests.post(
+            urls.predict_url, json={"code": status.HTTP_204_NO_CONTENT}
+        )
         assert response.status_code == 204
         assert "x-baseten-error-source" not in response.headers
         assert "x-baseten-error-code" not in response.headers
 
         response = requests.post(
-            PREDICT_URL, json={"code": status.HTTP_500_INTERNAL_SERVER_ERROR}
+            urls.predict_url, json={"code": status.HTTP_500_INTERNAL_SERVER_ERROR}
         )
         assert response.status_code == 500
         assert response.headers["x-baseten-error-source"] == "04"
@@ -1453,10 +1404,10 @@ def text_generator():
     """
 
     with ensure_kill_all(), _temp_truss(model) as tr:
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
         # A request for which response is not completely read.
-        predict_response = requests.post(PREDICT_URL, json={}, stream=True)
+        predict_response = requests.post(urls.predict_url, json={}, stream=True)
         assert (
             predict_response.headers["Content-Type"]
             == "text/event-stream; charset=utf-8"
@@ -1485,9 +1436,9 @@ def postprocess(self, inputs):
              return {**inputs, "postprocess": "was here"}
     """
     with ensure_kill_all(), _temp_truss(model) as tr:
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={"test": 123})
+        response = requests.post(urls.predict_url, json={"test": 123})
         assert response.status_code == 200
         assert response.json() == {
             "test": 123,
@@ -1503,9 +1454,7 @@ class Model:
         def predict(self, inputs, invalid_arg): ...
     """
     with ensure_kill_all(), _temp_truss(model) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=False
-        )
+        container, urls = tr.docker_run_for_test(wait_for_server_ready=False)
         time.sleep(1.0)  # Wait for logs.
         _assert_logs_contain_error(
             container.logs(),
@@ -1520,9 +1469,7 @@ class Model:
         def predict(self, request: fastapi.Request, invalid_arg): ...
     """
     with ensure_kill_all(), _temp_truss(model) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=False
-        )
+        container, urls = tr.docker_run_for_test(wait_for_server_ready=False)
         time.sleep(1.0)  # Wait for logs.
         _assert_logs_contain_error(
             container.logs(),
@@ -1538,9 +1485,7 @@ class Model:
         def predict(self, inputs, request: fastapi.Request, something): ...
     """
     with ensure_kill_all(), _temp_truss(model) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=False
-        )
+        container, urls = tr.docker_run_for_test(wait_for_server_ready=False)
         time.sleep(1.0)  # Wait for logs.
         _assert_logs_contain_error(
             container.logs(),
@@ -1559,9 +1504,7 @@ async def preprocess(self, inputs): ...
         def predict(self, request: fastapi.Request): ...
     """
     with ensure_kill_all(), _temp_truss(model) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=False
-        )
+        container, urls = tr.docker_run_for_test(wait_for_server_ready=False)
         time.sleep(1.0)  # Wait for logs.
         _assert_logs_contain_error(
             container.logs(),
@@ -1579,9 +1522,7 @@ async def predict(self, inputs, request: fastapi.Request): ...
         def postprocess(self, request: fastapi.Request): ...
     """
     with ensure_kill_all(), _temp_truss(model) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=False
-        )
+        container, urls = tr.docker_run_for_test(wait_for_server_ready=False)
         time.sleep(1.0)  # Wait for logs.
         _assert_logs_contain_error(
             container.logs(),
@@ -1595,9 +1536,7 @@ class Model:
         def preprocess(self, inputs): ...
     """
     with ensure_kill_all(), _temp_truss(model) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=False
-        )
+        container, urls = tr.docker_run_for_test(wait_for_server_ready=False)
         time.sleep(1.0)  # Wait for logs.
         _assert_logs_contain_error(
             container.logs(),
@@ -1618,11 +1557,9 @@ def postprocess(self, inputs):
              return inputs
     """
     with ensure_kill_all(), _temp_truss(model) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={})
+        response = requests.post(urls.predict_url, json={})
         assert response.status_code == 500
         assert response.headers["x-baseten-error-source"] == "04"
         assert response.headers["x-baseten-error-code"] == "600"
@@ -1654,15 +1591,13 @@ async def predict(self, inputs, request: fastapi.Request):
                     return
     """
     with ensure_kill_all(), _temp_truss(model) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
         # For hard cancellation we need to use httpx, requests' timeouts don't work.
         with pytest.raises(httpx.ReadTimeout):
             with httpx.Client(
                 timeout=httpx.Timeout(1.0, connect=1.0, read=1.0)
             ) as client:
-                response = client.post(PREDICT_URL, json={}, timeout=1.0)
+                response = client.post(urls.predict_url, json={}, timeout=1.0)
                 response.raise_for_status()
 
         time.sleep(2)  # Wait a bit to get all logs.
@@ -1686,15 +1621,13 @@ async def predict(self, inputs, request: fastapi.Request):
             return "Done"
     """
     with ensure_kill_all(), _temp_truss(model) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
         # For hard cancellation we need to use httpx, requests' timeouts don't work.
         with pytest.raises(httpx.ReadTimeout):
             with httpx.Client(
                 timeout=httpx.Timeout(1.0, connect=1.0, read=1.0)
             ) as client:
-                response = client.post(PREDICT_URL, json={}, timeout=1.0)
+                response = client.post(urls.predict_url, json={}, timeout=1.0)
                 response.raise_for_status()
 
         time.sleep(2)  # Wait a bit to get all logs.
@@ -1729,7 +1662,7 @@ def make_request(consume_chunks, timeout, task_id):
         t0 = time.time()
         with httpx.Client() as client:
             with client.stream(
-                "POST", PREDICT_URL, json={"task_id": task_id}
+                "POST", urls.predict_url, json={"task_id": task_id}
             ) as response:
                 assert response.status_code == 200
                 if consume_chunks:
@@ -1746,7 +1679,7 @@ def make_request(consume_chunks, timeout, task_id):
                     print(f"waiting done ({task_id})")
 
     with ensure_kill_all(), _temp_truss(model, config) as tr:
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
         # Processing full request takes 0.5s.
         print("Make warmup request")
         make_request(consume_chunks=True, timeout=0.55, task_id=0)
@@ -1791,17 +1724,17 @@ async def completions(self, inputs) -> int:
             return self._completions_count
     """
     with ensure_kill_all(), _temp_truss(model) as tr:
-        tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={"increment": 1})
+        response = requests.post(urls.predict_url, json={"increment": 1})
         assert response.status_code == 200
         assert response.json() == 1
 
-        response = requests.post(COMPLETIONS_URL, json={"increment": 2})
+        response = requests.post(urls.completions_url, json={"increment": 2})
         assert response.status_code == 200
         assert response.json() == 2
 
-        response = requests.post(CHAT_COMPLETIONS_URL, json={"increment": 3})
+        response = requests.post(urls.chat_completions_url, json={"increment": 3})
         assert response.status_code == 404
 
 
@@ -1823,9 +1756,11 @@ async def postprocess(self, nums: List[str]) -> Generator[str, None, None]:
                 yield num
     """
     with ensure_kill_all(), _temp_truss(model) as tr:
-        tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={"nums": ["1", "2"]}, stream=True)
+        response = requests.post(
+            urls.predict_url, json={"nums": ["1", "2"]}, stream=True
+        )
         assert response.headers.get("transfer-encoding") == "chunked"
         assert [
             byte_string.decode() for byte_string in list(response.iter_content())
@@ -1849,9 +1784,9 @@ async def predict(self, nums: AsyncGenerator[str, None]) -> List[str]:
             return [num async for num in nums]
     """
     with ensure_kill_all(), _temp_truss(model) as tr:
-        tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(PREDICT_URL, json={"nums": ["1", "2"]})
+        response = requests.post(urls.predict_url, json={"nums": ["1", "2"]})
         assert response.status_code == 200
         assert response.json() == ["1", "2"]
 
@@ -1873,10 +1808,10 @@ async def predict(self, inputs):
             pass
     """
     with ensure_kill_all(), _temp_truss(model) as tr:
-        tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
         response = requests.post(
-            CHAT_COMPLETIONS_URL,
+            urls.chat_completions_url,
             json={"nums": ["1", "2"]},
             stream=True,
             # Despite requesting json, we should still stream results back.
@@ -1903,9 +1838,7 @@ async def predict(self, inputs):
             pass
     """
     with ensure_kill_all(), _temp_truss(model, "") as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=False
-        )
+        container, urls = tr.docker_run_for_test(wait_for_server_ready=False)
         time.sleep(3)
         _assert_logs_contain_error(
             container.logs(),
@@ -1926,9 +1859,7 @@ async def preprocess(self, inputs):
             pass
     """
     with ensure_kill_all(), _temp_truss(model, "") as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=False
-        )
+        container, urls = tr.docker_run_for_test(wait_for_server_ready=False)
         time.sleep(3)
         _assert_logs_contain_error(
             container.logs(),
@@ -1945,9 +1876,7 @@ class Model:
        pass
     """
     with ensure_kill_all(), _temp_truss(model, "") as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=False
-        )
+        container, urls = tr.docker_run_for_test(wait_for_server_ready=False)
         time.sleep(1)
         _assert_logs_contain_error(
             container.logs(),
@@ -1976,8 +1905,8 @@ async def websocket(self, websocket: fastapi.WebSocket):
                 pass
     """
     with ensure_kill_all(), _temp_truss(model) as tr:
-        tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
-        async with websockets.connect(WEBSOCKETS_URL) as websocket:
+        container, urls = tr.docker_run_for_test()
+        async with websockets.connect(urls.websockets_url) as websocket:
             # Send "hello" and verify response
             await websocket.send("hello")
             response = await websocket.recv()
@@ -2015,10 +1944,8 @@ async def websocket(self, websocket: fastapi.WebSocket):
                 pass
     """
     with ensure_kill_all(), _temp_truss(model) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
-        async with websockets.connect(WEBSOCKETS_URL) as websocket:
+        container, urls = tr.docker_run_for_test()
+        async with websockets.connect(urls.websockets_url) as websocket:
             # Send "hello" and verify response
             await websocket.send("hello")
             response = await websocket.recv()
@@ -2058,11 +1985,9 @@ async def predict(self, inputs):
             pass
     """
     with ensure_kill_all(), _temp_truss(model) as tr:
-        container = tr.docker_run(
-            local_port=8090, detach=True, wait_for_server_ready=True
-        )
+        container, urls = tr.docker_run_for_test()
         with pytest.raises(websockets.ConnectionClosedError) as exc_info:
-            async with websockets.connect(WEBSOCKETS_URL) as ws:
+            async with websockets.connect(urls.websockets_url) as ws:
                 await ws.recv()
 
         assert exc_info.value.rcvd.code == 1003
diff --git a/truss/tests/test_model_schema.py b/truss/tests/test_model_schema.py
index b1580bc93..637c43f6d 100644
--- a/truss/tests/test_model_schema.py
+++ b/truss/tests/test_model_schema.py
@@ -11,9 +11,6 @@
 from truss.truss_handle.truss_handle import TrussHandle
 
 DEFAULT_CONFIG = """model_name: test-truss"""
-TRUSS_SERVER_ADDR = "http://localhost:8090"
-INFERENCE_URL = f"{TRUSS_SERVER_ADDR}/v1/models/model:predict"
-SCHEMA_URL = f"{TRUSS_SERVER_ADDR}/v1/models/model/schema"
 
 
 @pytest.mark.integration
@@ -23,12 +20,12 @@ def test_truss_with_no_annotations(test_data_path):
     tr = TrussHandle(truss_dir)
 
     with ensure_kill_all():
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(INFERENCE_URL, json={"prompt": "value"})
+        response = requests.post(urls.predict_url, json={"prompt": "value"})
         assert response.json() == {"prompt": "value"}
 
-        schema_response = requests.get(SCHEMA_URL)
+        schema_response = requests.get(urls.schema_url)
         assert schema_response.status_code == 404
 
         assert schema_response.json()["error"] == "No schema found"
@@ -50,12 +47,12 @@ def predict(self, request: str) -> list[str]:
         create_truss(truss_dir, DEFAULT_CONFIG, truss_non_pydantic_annotations)
 
         tr = TrussHandle(truss_dir)
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(INFERENCE_URL, json={"prompt": "value"})
+        response = requests.post(urls.predict_url, json={"prompt": "value"})
         assert response.json() == ["hello"]
 
-        schema_response = requests.get(SCHEMA_URL)
+        schema_response = requests.get(urls.schema_url)
         assert schema_response.status_code == 404
         assert schema_response.json()["error"] == "No schema found"
         assert schema_response.headers["x-baseten-error-source"] == "04"
@@ -79,12 +76,12 @@ def predict(self, request):
         create_truss(truss_dir, DEFAULT_CONFIG, truss_long_load)
 
         tr = TrussHandle(truss_dir)
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=False)
+        container, urls = tr.docker_run_for_test(wait_for_server_ready=False)
 
         # Wait a bit for the server to start
         time.sleep(2)
 
-        schema_response = requests.get(SCHEMA_URL)
+        schema_response = requests.get(urls.schema_url)
 
         # If the load has not successfully completed,
         # we return a 503 instead of 404, so that clients
@@ -99,24 +96,24 @@ def test_truss_with_annotated_inputs_outputs(test_data_path):
     tr = TrussHandle(truss_dir)
 
     with ensure_kill_all():
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
         # Valid JSON input.
         json_input = {"prompt": "value"}
-        response = requests.post(INFERENCE_URL, json=json_input)
+        response = requests.post(urls.predict_url, json=json_input)
         assert response.json() == {"generated_text": "value"}
 
         # Valid binary input.
         byte_input = serialization.truss_msgpack_serialize(json_input)
         print(byte_input)
         response = requests.post(
-            INFERENCE_URL,
+            urls.predict_url,
             data=byte_input,
             headers={"Content-Type": "application/octet-stream"},
         )
         assert response.content == b"\x81\xaegenerated_text\xa5value"
 
         # An invalid input
-        response = requests.post(INFERENCE_URL, json={"bad_key": "value"})
+        response = requests.post(urls.predict_url, json={"bad_key": "value"})
         assert response.status_code == 400
         assert "error" in response.json()
         assert (
@@ -127,7 +124,7 @@ def test_truss_with_annotated_inputs_outputs(test_data_path):
         assert response.headers["x-baseten-error-code"] == "700"
 
         # Schema response.
-        schema_response = requests.get(SCHEMA_URL)
+        schema_response = requests.get(urls.schema_url)
         schema = schema_response.json()
         assert schema["input_schema"] == {
             "properties": {"prompt": {"title": "Prompt", "type": "string"}},
@@ -169,12 +166,12 @@ def predict(self, request: ModelInput) -> Union[ModelOutput, Generator[str, None
         create_truss(truss_dir, DEFAULT_CONFIG, streaming_truss)
 
         tr = TrussHandle(truss_dir)
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
-        response = requests.post(INFERENCE_URL, json={"prompt": "value"})
+        container, urls = tr.docker_run_for_test()
+        response = requests.post(urls.predict_url, json={"prompt": "value"})
 
         assert response.json() == {"generated_text": "value"}
 
-        schema_response = requests.get(SCHEMA_URL)
+        schema_response = requests.get(urls.schema_url)
 
         schema = schema_response.json()
 
@@ -221,13 +218,13 @@ def inner():
 
         create_truss(truss_dir, DEFAULT_CONFIG, streaming_truss)
         tr = TrussHandle(truss_dir)
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(INFERENCE_URL, json={"prompt": "value"})
+        response = requests.post(urls.predict_url, json={"prompt": "value"})
 
         assert response.content == b"01"
 
-        schema_response = requests.get(SCHEMA_URL)
+        schema_response = requests.get(urls.schema_url)
 
         schema = schema_response.json()
 
@@ -266,13 +263,13 @@ async def predict(self, request: ModelInput) -> Awaitable[ModelOutput]:
 
         create_truss(truss_dir, DEFAULT_CONFIG, truss_contents)
         tr = TrussHandle(truss_dir)
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(INFERENCE_URL, json={"prompt": "value"})
+        response = requests.post(urls.predict_url, json={"prompt": "value"})
 
         assert response.json() == {"generated_text": "value"}
 
-        schema_response = requests.get(SCHEMA_URL)
+        schema_response = requests.get(urls.schema_url)
 
         schema = schema_response.json()
 
@@ -317,13 +314,13 @@ async def predict(self, prompt: ModelInput) -> AsyncGenerator[str, None]:
 
         create_truss(truss_dir, DEFAULT_CONFIG, streaming_truss)
         tr = TrussHandle(truss_dir)
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
-        response = requests.post(INFERENCE_URL, json={"prompt": "value"})
+        response = requests.post(urls.predict_url, json={"prompt": "value"})
 
         assert response.content == b"01"
 
-        schema_response = requests.get(SCHEMA_URL)
+        schema_response = requests.get(urls.schema_url)
 
         schema = schema_response.json()
 
@@ -368,23 +365,23 @@ def inner():
         create_truss(truss_dir, DEFAULT_CONFIG, streaming_truss)
 
         tr = TrussHandle(truss_dir)
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
         response = requests.post(
-            INFERENCE_URL, json={"prompt": "value", "stream": False}
+            urls.predict_url, json={"prompt": "value", "stream": False}
         )
 
         assert response.json() == {"generated_text": "value"}
         assert response.status_code == 200
 
         response = requests.post(
-            INFERENCE_URL, json={"prompt": "value", "stream": True}
+            urls.predict_url, json={"prompt": "value", "stream": True}
         )
 
         assert response.status_code == 200
         assert response.content == b"01"
 
-        schema_response = requests.get(SCHEMA_URL)
+        schema_response = requests.get(urls.schema_url)
 
         schema = schema_response.json()
 
@@ -437,16 +434,16 @@ def postprocess(self, request: str) -> ModelOutput:
         create_truss(truss_dir, DEFAULT_CONFIG, streaming_truss)
 
         tr = TrussHandle(truss_dir)
-        _ = tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        container, urls = tr.docker_run_for_test()
 
         response = requests.post(
-            INFERENCE_URL, json={"prompt": "value", "stream": False}
+            urls.predict_url, json={"prompt": "value", "stream": False}
         )
 
         assert response.json() == {"generated_text": "value"}
         assert response.status_code == 200
 
-        schema_response = requests.get(SCHEMA_URL)
+        schema_response = requests.get(urls.schema_url)
 
         schema = schema_response.json()
 
diff --git a/truss/tests/test_truss_handle.py b/truss/tests/test_truss_handle.py
index 7c566a670..6262355fd 100644
--- a/truss/tests/test_truss_handle.py
+++ b/truss/tests/test_truss_handle.py
@@ -23,7 +23,7 @@
     kill_all_with_retries,
 )
 from truss.truss_handle.patch.custom_types import PatchRequest
-from truss.truss_handle.truss_handle import TrussHandle, wait_for_truss
+from truss.truss_handle.truss_handle import DockerURLs, TrussHandle, wait_for_truss
 from truss.util.docker import Docker, DockerStates
 
 
@@ -133,7 +133,7 @@ def test_docker_predict_custom_base_image(custom_model_truss_dir_with_pre_and_po
         "wallies/python-cuda:3.10-cuda11.7-runtime", "/usr/local/bin/python"
     )
     with ensure_kill_all():
-        result = th.docker_predict([1, 2])
+        result = th.docker_predict([1, 2], local_port=None)
         assert result == {"predictions": [4, 5]}
 
 
@@ -160,7 +160,7 @@ def test_build_docker_image_control_gpu(custom_model_truss_dir_for_gpu, tmp_path
 def test_docker_run(custom_model_truss_dir_with_pre_and_post):
     th = TrussHandle(custom_model_truss_dir_with_pre_and_post)
     tag = "test-docker-run-tag:0.0.1"
-    container = th.docker_run(tag=tag)
+    container = th.docker_run(tag=tag, local_port=None)
     try:
         assert _container_exists(container)
     finally:
@@ -172,7 +172,7 @@ def test_docker_run(custom_model_truss_dir_with_pre_and_post):
 def test_docker_run_gpu(custom_model_truss_dir_for_gpu):
     th = TrussHandle(custom_model_truss_dir_for_gpu)
     tag = "test-docker-run-gpu-tag:0.0.1"
-    container = th.docker_run(tag=tag)
+    container = th.docker_run(tag=tag, local_port=None)
     try:
         assert _container_exists(container)
     finally:
@@ -182,7 +182,7 @@ def test_docker_run_gpu(custom_model_truss_dir_for_gpu):
 @pytest.mark.integration
 def test_docker_run_without_tag(custom_model_truss_dir_with_pre_and_post):
     th = TrussHandle(custom_model_truss_dir_with_pre_and_post)
-    container = th.docker_run()
+    container = th.docker_run(local_port=None)
     try:
         assert _container_exists(container)
     finally:
@@ -194,9 +194,9 @@ def get_docker_containers_from_labels(custom_model_truss_dir_with_pre_and_post):
     with ensure_kill_all():
         t1 = TrussHandle(custom_model_truss_dir_with_pre_and_post)
         assert len(t1.get_serving_docker_containers_from_labels()) == 0
-        t1.docker_run()
+        t1.docker_run(local_port=None)
         assert len(t1.get_serving_docker_containers_from_labels()) == 1
-        t1.docker_run(local_port=3000)
+        t1.docker_run(local_port=None)
         assert len(t1.get_serving_docker_containers_from_labels()) == 2
         t1.kill_container()
         assert len(t1.get_serving_docker_containers_from_labels()) == 0
@@ -216,7 +216,7 @@ def test_docker_predict(custom_model_truss_dir_with_pre_and_post):
     th = TrussHandle(custom_model_truss_dir_with_pre_and_post)
     tag = "test-docker-predict-tag:0.0.1"
     with ensure_kill_all():
-        result = th.docker_predict([1, 2], tag=tag)
+        result = th.docker_predict([1, 2], tag=tag, local_port=None)
         assert result == {"predictions": [4, 5]}
 
 
@@ -227,7 +227,7 @@ def test_docker_predict_model_with_external_packages(
     th = TrussHandle(custom_model_with_external_package)
     tag = "test-docker-predict-ext-pkg-tag:0.0.1"
     with ensure_kill_all():
-        result = th.docker_predict([1, 2], tag=tag)
+        result = th.docker_predict([1, 2], tag=tag, local_port=None)
         assert result == [1, 1]
 
 
@@ -238,7 +238,7 @@ def test_docker_predict_with_bundled_packages(
     th = TrussHandle(custom_model_truss_dir_with_bundled_packages)
     tag = "test-docker-predict-bundled-packages-tag:0.0.1"
     with ensure_kill_all():
-        result = th.docker_predict([1, 2], tag=tag)
+        result = th.docker_predict([1, 2], tag=tag, local_port=None)
         assert result == {"predictions": [1]}
 
 
@@ -247,8 +247,8 @@ def test_docker_multiple_predict(custom_model_truss_dir_with_pre_and_post):
     th = TrussHandle(custom_model_truss_dir_with_pre_and_post)
     tag = "test-docker-predict-tag:0.0.1"
     with ensure_kill_all():
-        r1 = th.docker_predict([1, 2], tag=tag)
-        r2 = th.docker_predict([3, 4], tag=tag)
+        r1 = th.docker_predict([1, 2], tag=tag, local_port=None)
+        r2 = th.docker_predict([3, 4], tag=tag, local_port=None)
         assert r1 == {"predictions": [4, 5]}
         assert r2 == {"predictions": [6, 7]}
         assert len(th.get_serving_docker_containers_from_labels()) == 1
@@ -259,9 +259,9 @@ def test_kill_all(custom_model_truss_dir, custom_model_truss_dir_with_pre_and_po
     t1 = TrussHandle(custom_model_truss_dir_with_pre_and_post)
     t2 = TrussHandle(custom_model_truss_dir)
     with ensure_kill_all():
-        t1.docker_run()
+        t1.docker_run(local_port=None)
         assert len(t1.get_serving_docker_containers_from_labels()) == 1
-        t2.docker_run(local_port=3000)
+        t2.docker_run(local_port=None)
         assert len(t2.get_serving_docker_containers_from_labels()) == 1
         kill_all_with_retries()
         assert len(t1.get_serving_docker_containers_from_labels()) == 0
@@ -274,7 +274,7 @@ def test_docker_predict_gpu(custom_model_truss_dir_for_gpu):
     th = TrussHandle(custom_model_truss_dir_for_gpu)
     tag = "test-docker-predict-gpu-tag:0.0.1"
     with ensure_kill_all():
-        result = th.docker_predict([1], tag=tag)
+        result = th.docker_predict([1], tag=tag, local_port=None)
         assert result["predictions"][0]["cuda_version"].startswith("11")
 
 
@@ -285,7 +285,9 @@ def test_docker_predict_secrets(custom_model_truss_dir_for_secrets):
     LocalConfigHandler.set_secret("secret_name", "secret_value")
     with ensure_kill_all():
         try:
-            result = th.docker_predict({"instances": ["secret_name"]}, tag=tag)
+            result = th.docker_predict(
+                {"instances": ["secret_name"]}, tag=tag, local_port=None
+            )
             assert result["predictions"][0] == "secret_value"
         finally:
             LocalConfigHandler.remove_secret("secret_name")
@@ -296,7 +298,7 @@ def test_docker_no_preprocess_custom_model(no_preprocess_custom_model):
     th = TrussHandle(no_preprocess_custom_model)
     tag = "test-docker-no-preprocess-tag:0.0.1"
     with ensure_kill_all():
-        result = th.docker_predict([1], tag=tag)
+        result = th.docker_predict([1], tag=tag, local_port=None)
         assert result["predictions"][0] == 2
 
 
@@ -305,7 +307,7 @@ def test_docker_long_load(long_load_model):
     th = TrussHandle(long_load_model)
     tag = "test-docker-long-load-tag:0.0.1"
     with ensure_kill_all():
-        result = th.docker_predict([1], tag=tag)
+        result = th.docker_predict([1], tag=tag, local_port=None)
         assert result["predictions"][0] == 1
 
 
@@ -321,7 +323,7 @@ def test_docker_no_postprocess_custom_model(no_postprocess_custom_model):
     th = TrussHandle(no_postprocess_custom_model)
     tag = "test-docker-no-postprocess-tag:0.0.1"
     with ensure_kill_all():
-        result = th.docker_predict([1], tag=tag)
+        result = th.docker_predict([1], tag=tag, local_port=None)
         assert result["predictions"][0] == 2
 
 
@@ -337,7 +339,7 @@ def test_docker_no_load_custom_model(no_load_custom_model):
     th = TrussHandle(no_load_custom_model)
     tag = "test-docker-no-load-tag:0.0.1"
     with ensure_kill_all():
-        result = th.docker_predict([1], tag=tag)
+        result = th.docker_predict([1], tag=tag, local_port=None)
         assert result["predictions"][0] == 1
 
 
@@ -353,7 +355,7 @@ def test_docker_no_params_init_custom_model(no_params_init_custom_model):
     th = TrussHandle(no_params_init_custom_model)
     tag = "test-docker-no-params-init-tag:0.0.1"
     with ensure_kill_all():
-        result = th.docker_predict([1], tag=tag)
+        result = th.docker_predict([1], tag=tag, local_port=None)
         assert result["predictions"][0] == 1
 
 
@@ -370,7 +372,7 @@ def test_custom_python_requirement(custom_model_truss_dir_with_pre_and_post):
     th.add_python_requirement("theano")
     th.add_python_requirement("scipy")
     tag = "test-custom-python-req-tag:0.0.1"
-    container = th.docker_run(tag=tag)
+    container = th.docker_run(tag=tag, local_port=None)
     try:
         verify_python_requirement_installed_on_container(container, "theano")
         verify_python_requirement_installed_on_container(container, "scipy")
@@ -384,7 +386,7 @@ def test_custom_system_package(custom_model_truss_dir_with_pre_and_post):
     th.add_system_package("jq")
     th.add_system_package("fzf")
     tag = "test-custom-system-package-tag:0.0.1"
-    container = th.docker_run(tag=tag)
+    container = th.docker_run(tag=tag, local_port=None)
     try:
         verify_system_package_installed_on_container(container, "jq")
         verify_system_package_installed_on_container(container, "fzf")
@@ -437,7 +439,7 @@ def test_add_environment_variable(custom_model_truss_dir_with_pre_and_post):
     th = TrussHandle(custom_model_truss_dir_with_pre_and_post)
     th.add_environment_variable("test_env", "test_value")
     tag = "test-add-env-var-tag:0.0.1"
-    container = th.docker_run(tag=tag)
+    container = th.docker_run(tag=tag, local_port=None)
     try:
         verify_environment_variable_on_container(container, "test_env", "test_value")
     finally:
@@ -449,7 +451,7 @@ def test_build_commands(test_data_path):
     truss_dir = test_data_path / "test_build_commands"
     tr = TrussHandle(truss_dir)
     with ensure_kill_all():
-        r1 = tr.docker_predict([1, 2])
+        r1 = tr.docker_predict([1, 2], local_port=None)
         assert r1 == {"predictions": [1, 2]}
 
 
@@ -458,7 +460,7 @@ def test_build_commands_failure(test_data_path):
     truss_dir = test_data_path / "test_build_commands_failure"
     tr = TrussHandle(truss_dir)
     try:
-        tr.docker_run(local_port=8090, detach=True, wait_for_server_ready=True)
+        tr.docker_run(local_port=None, detach=True, wait_for_server_ready=True)
     except DockerException as exc:
         assert "It returned with code 1" in str(exc)
 
@@ -545,7 +547,7 @@ def test_model_without_pre_post(custom_model_truss_dir):
 def test_docker_predict_model_without_pre_post(custom_model_truss_dir):
     th = TrussHandle(custom_model_truss_dir)
     with ensure_kill_all():
-        resp = th.docker_predict([1, 2, 3, 4])
+        resp = th.docker_predict([1, 2, 3, 4], local_port=None)
         assert resp == [1, 1, 1, 1]
 
 
@@ -554,7 +556,7 @@ def test_control_truss_apply_patch(custom_model_control):
     th = TrussHandle(custom_model_control)
     tag = "test-docker-custom-model-control-tag:0.0.1"
     with ensure_kill_all():
-        result = th.docker_predict([1], tag=tag)
+        result = th.docker_predict([1], tag=tag, local_port=None)
         assert result[0] == 1
 
         running_hash = th.truss_hash_on_serving_container()
@@ -577,7 +579,7 @@ def predict(self, model_input):
         )
 
         th.patch_container(patch_request)
-        result = th.docker_predict([1], tag=tag)
+        result = th.docker_predict([1], tag=tag, local_port=None)
         assert result[0] == 2
 
 
@@ -586,12 +588,12 @@ def test_regular_truss_local_update_flow(custom_model_truss_dir):
     th = TrussHandle(custom_model_truss_dir)
     tag = "test-docker-custom-model-tag:0.0.1"
     with ensure_kill_all():
-        result = th.docker_predict([1], tag=tag)
+        result = th.docker_predict([1], tag=tag, local_port=None)
         assert result[0] == 1
         orig_num_truss_images = len(th.get_all_docker_images())
 
         # No new docker images on second predict
-        result = th.docker_predict([1], tag=tag)
+        result = th.docker_predict([1], tag=tag, local_port=None)
         assert orig_num_truss_images == len(th.get_all_docker_images())
 
         with (custom_model_truss_dir / "model" / "model.py").open(
@@ -604,7 +606,7 @@ def predict(self, model_input):
         return [2 for i in model_input]
 """
             )
-        result = th.docker_predict([1], tag=tag)
+        result = th.docker_predict([1], tag=tag, local_port=None)
         assert result[0] == 2
         # A new image should have been created
         assert len(th.get_all_docker_images()) == orig_num_truss_images + 1
@@ -628,19 +630,25 @@ def test_truss_hash_caching_based_on_max_mod_time(
     directory_content_patcher.call_count == 2
 
 
+@patch("truss.truss_handle.truss_handle.get_docker_urls")
 @patch("truss.truss_handle.truss_handle.get_container_state")
-def test_container_oom_caught_during_waiting(container_state_mock):
+def test_container_oom_caught_during_waiting(
+    container_state_mock, get_docker_urls_mock
+):
     container_state_mock.return_value = DockerStates.OOMKILLED
+    get_docker_urls_mock.return_value = DockerURLs("http://localhost:8080")
     with pytest.raises(ContainerIsDownError):
-        wait_for_truss(url="localhost:8000", container=MagicMock())
+        wait_for_truss(container=MagicMock())
 
 
+@patch("truss.truss_handle.truss_handle.get_docker_urls")
 @patch("truss.truss_handle.truss_handle.get_container_state")
 @pytest.mark.integration
-def test_container_stuck_in_created(container_state_mock):
+def test_container_stuck_in_created(container_state_mock, get_docker_urls_mock):
     container_state_mock.return_value = DockerStates.CREATED
+    get_docker_urls_mock.return_value = DockerURLs("http://localhost:8080")
     with pytest.raises(ContainerIsDownError):
-        wait_for_truss(url="localhost:8000", container=MagicMock())
+        wait_for_truss(container=MagicMock())
 
 
 @pytest.mark.integration
@@ -648,7 +656,7 @@ def test_control_truss_local_update_that_crashes_inference_server(custom_model_c
     th = TrussHandle(custom_model_control)
     tag = "test-docker-custom-model-control-tag:0.0.1"
     with ensure_kill_all():
-        result = th.docker_predict([1], tag=tag)
+        result = th.docker_predict([1], tag=tag, local_port=None)
         assert result[0] == 1
 
         bad_model_code = """
@@ -659,10 +667,13 @@ def malformed
         with model_code_file_path.open("w") as model_code_file:
             model_code_file.write(bad_model_code)
         with pytest.raises(RetryError) as exc_info:
-            th.docker_predict([1], tag=tag)
+            th.docker_predict([1], tag=tag, local_port=None)
         resp = exc_info.value.last_attempt.result()
         assert resp.status_code == 503
-        assert "Model load failed" in resp.text
+        assert (
+            "Model load failed" in resp.text
+            or "It appears your model has stopped running" in resp.text
+        )
 
         # Should be able to fix code after
         good_model_code = """
@@ -672,7 +683,7 @@ def predict(self, model_input):
 """
         with model_code_file_path.open("w") as model_code_file:
             model_code_file.write(good_model_code)
-        result = th.docker_predict([1], tag=tag)
+        result = th.docker_predict([1], tag=tag, local_port=None)
         assert result[0] == 2
 
 
@@ -689,7 +700,9 @@ def test_patch_ping_flow(
     th = TrussHandle(custom_model_control)
     tag = "test-docker-custom-model-control-tag:0.0.1"
     with ensure_kill_all():
-        result = th.docker_predict([1], tag=tag, patch_ping_url=patch_ping_url)
+        result = th.docker_predict(
+            [1], tag=tag, patch_ping_url=patch_ping_url, local_port=None
+        )
         assert result == [1]
 
         # Make sure the patch ping url was actually hit
@@ -706,7 +719,7 @@ def return_container_dne(self):
         pytest.raises(ContainerNotFoundError),
     ):
         truss_handle = TrussHandle(truss_dir=custom_model_truss_dir)
-        truss_handle.docker_run(local_port=3000)
+        truss_handle.docker_run(local_port=None)
     kill_all_with_retries()
 
 
@@ -719,7 +732,7 @@ def return_container_dne(self):
         pytest.raises(ContainerNotFoundError),
     ):
         truss_handle = TrussHandle(truss_dir=custom_model_truss_dir)
-        truss_handle.docker_predict([1], local_port=3000)
+        truss_handle.docker_predict([1], local_port=None)
     kill_all_with_retries()
 
 
@@ -729,7 +742,7 @@ def test_external_data(custom_model_external_data_access_tuple_fixture):
     th = TrussHandle(truss_dir)
     tag = "test-external-data-access-tag:0.0.1"
     with ensure_kill_all():
-        result = th.docker_predict([], tag=tag, network="host")
+        result = th.docker_predict([], tag=tag, network="host", local_port=None)
         assert result == expected_content
 
 
@@ -739,7 +752,7 @@ def test_external_data_gpu(custom_model_external_data_access_tuple_fixture_gpu):
     th = TrussHandle(truss_dir)
     tag = "test-external-data-access-tag:0.0.1"
     with ensure_kill_all():
-        result = th.docker_predict([], tag=tag, network="host")
+        result = th.docker_predict([], tag=tag, network="host", local_port=None)
         assert result == expected_content
 
 
diff --git a/truss/truss_handle/truss_handle.py b/truss/truss_handle/truss_handle.py
index 63226b452..56cf1c942 100644
--- a/truss/truss_handle/truss_handle.py
+++ b/truss/truss_handle/truss_handle.py
@@ -5,7 +5,7 @@
 import sys
 import uuid
 from pathlib import Path
-from typing import Any, Dict, List, Optional, Tuple, Type, Union
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type, Union
 from urllib.error import HTTPError
 
 import requests
@@ -14,7 +14,6 @@
 from requests.exceptions import ConnectionError
 from requests.models import Response
 from tenacity import (
-    RetryError,
     Retrying,
     retry,
     retry_if_exception_type,
@@ -74,6 +73,10 @@
     load_trussignore_patterns,
 )
 
+if TYPE_CHECKING:
+    from python_on_whales.components.container.cli_wrapper import Container
+
+
 logger: logging.Logger = logging.getLogger(__name__)
 
 if is_notebook_or_ipython():
@@ -96,6 +99,26 @@ def wait(self):
         return docker.wait(self.container)
 
 
+class DockerURLs:
+    def __init__(self, base_url):
+        self.base_url = base_url
+
+        self.predict_url = f"{base_url}/v1/models/model:predict"
+        self.completions_url = f"{base_url}/v1/completions"
+        self.chat_completions_url = f"{base_url}/v1/chat/completions"
+
+        self.schema_url = f"{base_url}/v1/models/model/schema"
+        self.metrics_url = f"{base_url}/metrics"
+
+        self.patch_url = f"{base_url}/control/patch"
+        self.hash_url = f"{base_url}/control/truss_hash"
+        self.has_partially_applied_patch_url = (
+            f"{base_url}/control/has_partially_applied_patch"
+        )
+
+        self.websockets_url = f"{base_url}/v1/websocket".replace("http", "ws")
+
+
 class TrussHandle:
     def __init__(self, truss_dir: Path, validate: bool = True) -> None:
         self._truss_dir = truss_dir
@@ -126,20 +149,16 @@ def spec(self) -> TrussSpec:
         ),
     )
     def _wait_for_predict(
-        model_base_url: str, request: Dict, binary: bool = False
+        urls: DockerURLs, request: Dict, binary: bool = False
     ) -> Response:
-        url = f"{model_base_url}/v1/models/model:predict"
-
         if binary:
             binary_data = truss_msgpack_serialize(request)
-
             return requests.post(
-                url,
+                urls.predict_url,
                 data=binary_data,
                 headers={"Content-Type": "application/octet-stream"},
             )
-
-        return requests.post(url, json=request)
+        return requests.post(urls.predict_url, json=request)
 
     @proxy_to_shadow_if_scattered
     def build_docker_build_context(self, build_dir: Optional[Path] = None):
@@ -234,7 +253,7 @@ def docker_run(
         self,
         build_dir: Optional[Path] = None,
         tag: Optional[str] = None,
-        local_port: int = INFERENCE_SERVER_PORT,
+        local_port: Optional[int] = INFERENCE_SERVER_PORT,
         detach=True,
         patch_ping_url: Optional[str] = None,
         wait_for_server_ready: bool = True,
@@ -250,7 +269,7 @@ def docker_run(
         Args:
             build_dir: Directory to use for creating docker build context.
             tag: Tags to apply to docker image.
-            local_port: Local port to forward inference server to.
+            local_port: Local port to forward inference server to, if `None` any free is chosen.
             detach: Run docker container in detached mode.
             patch_ping_url:  Mostly for testing, if supplied then a live
                              reload capable truss queries for truss changes
@@ -266,6 +285,7 @@ def docker_run(
         """
         from python_on_whales.exceptions import DockerException
 
+        container: Union[Container, str]
         container_if_patched = self._try_patch()
         if container_if_patched is not None:
             container = container_if_patched
@@ -274,7 +294,11 @@ def docker_run(
                 build_dir=build_dir, tag=tag, network=network
             )
             secrets_mount_dir_path = _prepare_secrets_mount_dir()
-            publish_ports = [[local_port, INFERENCE_SERVER_PORT]]
+            publish_ports = (
+                [[local_port, INFERENCE_SERVER_PORT]]
+                if local_port is not None
+                else [[0, INFERENCE_SERVER_PORT]]
+            )
 
             # We are going to try running a new container, make sure previous one is gone
             self.kill_container()
@@ -332,16 +356,14 @@ def _run_docker(gpus: Optional[str] = None):
                 logger.warning("No GPU is available to docker. Running without a GPU.")
                 container = _run_docker(None)
 
+            urls = get_docker_urls(container)
             logger.info(
-                f"Model server started on port {local_port}, docker container id {container.id}"
+                f"Model server started on `{urls.base_url}`, docker container id {container}"
             )
-        model_base_url = f"http://localhost:{local_port}/v1/models/model"
+
         try:
             wait_for_truss(
-                model_base_url,
-                container,
-                wait_for_server_ready,
-                model_server_stop_retry_override,
+                container, wait_for_server_ready, model_server_stop_retry_override
             )
         except ContainerNotFoundError as err:
             raise err
@@ -351,6 +373,18 @@ def _run_docker(gpus: Optional[str] = None):
 
         return container
 
+    def docker_run_for_test(
+        self, wait_for_server_ready=True, model_server_stop_retry_override=None
+    ) -> tuple["Container", DockerURLs]:
+        container = self.docker_run(
+            local_port=None,
+            detach=True,
+            wait_for_server_ready=wait_for_server_ready,
+            network="host",
+            model_server_stop_retry_override=model_server_stop_retry_override,
+        )
+        return container, get_docker_urls(container)
+
     def predict(
         self,
         request: Dict,
@@ -385,7 +419,7 @@ def docker_predict(
         request: Dict,
         build_dir: Optional[Path] = None,
         tag: Optional[str] = None,
-        local_port: int = INFERENCE_SERVER_PORT,
+        local_port: Optional[int] = INFERENCE_SERVER_PORT,
         detach: bool = True,
         patch_ping_url: Optional[str] = None,
         binary: bool = False,
@@ -419,10 +453,10 @@ def docker_predict(
                 detach=detach,
                 patch_ping_url=patch_ping_url,
                 network=network,
+                wait_for_server_ready=True,
             )
-        model_base_url = _get_url_from_container(container)
-
-        resp = TrussHandle._wait_for_predict(model_base_url, request, binary)
+        urls = get_docker_urls(container)
+        resp = TrussHandle._wait_for_predict(urls, request, binary)
 
         if resp.status_code == 500:
             raise requests.exceptions.HTTPError("500 error", response=resp)
@@ -625,7 +659,7 @@ def get_docker_containers_from_labels(self, *args, **kwargs):
     @proxy_to_shadow_if_scattered
     def get_serving_docker_containers_from_labels(
         self, all: bool = False, labels: Optional[dict] = None
-    ) -> list:
+    ) -> list["Container"]:
         """Get serving docker containers, with given labels.
 
         Args:
@@ -640,12 +674,13 @@ def get_serving_docker_containers_from_labels(
 
         return sorted(get_containers(labels, all=all), key=lambda c: c.created)
 
-    def get_running_serving_container_ignore_hash(self):
+    def get_running_serving_container_ignore_hash(self) -> Optional["Container"]:
         containers = self.get_serving_docker_containers_from_labels(
             labels={TRUSS_DIR: str(self._truss_dir)}
         )
         if containers is not None and len(containers) > 0:
             return containers[0]
+        return None
 
     @proxy_to_shadow_if_scattered
     def kill_container(self):
@@ -697,11 +732,8 @@ def patch_container(self, patch_request: PatchRequest):
             raise ValueError(
                 "Only running trusses can be patched: no running containers found for this truss."
             )
-
-        model_base_url = _get_url_from_container(container)
-        resp = requests.post(
-            f"{model_base_url}/control/patch", json=patch_request.to_dict()
-        )
+        urls = get_docker_urls(container)
+        resp = requests.post(urls.patch_url, json=patch_request.to_dict())
         resp.raise_for_status()
         return resp.json()
 
@@ -718,8 +750,9 @@ def truss_hash_on_serving_container(self) -> Optional[str]:
             )
 
         container = self.get_running_serving_container_ignore_hash()
-        model_base_url = _get_url_from_container(container)
-        resp = requests.get(f"{model_base_url}/control/truss_hash")
+        assert container
+        urls = get_docker_urls(container)
+        resp = requests.get(urls.hash_url)
         resp.raise_for_status()
         respj = resp.json()
         if "error" in respj:
@@ -741,8 +774,9 @@ def _control_serving_container_has_partially_applied_patch(self) -> Optional[boo
             raise ValueError("Not a control truss, operation not supported.")
 
         container = self.get_running_serving_container_ignore_hash()
-        model_base_url = _get_url_from_container(container)
-        resp = requests.get(f"{model_base_url}/control/has_partially_applied_patch")
+        assert container
+        urls = get_docker_urls(container)
+        resp = requests.get(urls.has_partially_applied_patch_url)
         resp.raise_for_status()
         respj = resp.json()
         if "error" in respj:
@@ -903,7 +937,7 @@ def _update_config(self, **fields_to_update):
         config.write_to_yaml_file(self._spec.config_path)
         self._spec = TrussSpec(self._truss_dir)  # Reload.
 
-    def _try_patch(self):
+    def _try_patch(self) -> Optional["Container"]:
         if not self.is_control_truss:
             return None
 
@@ -984,7 +1018,7 @@ def _validate_external_packages(self):
                     )
 
     def _validate_extensions(self):
-        # Only one extenstion right now.
+        # Only one extension right now.
         if self._spec.config.trt_llm is not None:
             validate(self._spec)
 
@@ -1001,7 +1035,9 @@ def _prediction_flow(model, request: Dict):
 
 
 def _wait_for_docker_build(container) -> None:
-    for attempt in Retrying(stop=stop_after_attempt(5), wait=wait_fixed(2)):
+    for attempt in Retrying(
+        stop=stop_after_attempt(5), wait=wait_fixed(2), reraise=True
+    ):
         state = get_container_state(container)
         logger.info(f"Container state: {state}")
         if state == DockerStates.OOMKILLED or state == DockerStates.DEAD:
@@ -1011,7 +1047,7 @@ def _wait_for_docker_build(container) -> None:
                 raise ContainerIsDownError(f"Container stuck in state: {state.value}.")
 
 
-def _wait_for_model_server(url: str, stop=stop_after_delay(120)) -> Response:  # type: ignore[return]
+def _wait_for_model_server(url: str, stop: stop_after_delay) -> Response:  # type: ignore[return]
     for attempt in Retrying(
         stop=stop,
         wait=wait_fixed(2),
@@ -1019,6 +1055,7 @@ def _wait_for_model_server(url: str, stop=stop_after_delay(120)) -> Response:  #
             retry_if_result(lambda response: response.status_code in [502, 503])
             | retry_if_exception_type(exceptions.ConnectionError)
         ),
+        reraise=True,
     ):
         with attempt:
             response = requests.get(url)
@@ -1026,8 +1063,7 @@ def _wait_for_model_server(url: str, stop=stop_after_delay(120)) -> Response:  #
 
 
 def wait_for_truss(
-    url: str,
-    container: str,
+    container: Union[str, "Container"],
     wait_for_server_ready: bool = True,
     model_server_stop_retry_override=None,
 ) -> None:
@@ -1035,15 +1071,17 @@ def wait_for_truss(
 
     try:
         _wait_for_docker_build(container)
-        if wait_for_server_ready:
-            if model_server_stop_retry_override is not None:
-                _wait_for_model_server(url, stop=model_server_stop_retry_override)
-            else:
-                _wait_for_model_server(url)
+        urls = get_docker_urls(container)
     except NoSuchContainer:
         raise ContainerNotFoundError(message=f"Container {container} was not found")
-    except RetryError as retry_err:
-        retry_err.reraise()
+
+    if wait_for_server_ready:
+        if model_server_stop_retry_override is None:
+            stop = stop_after_delay(120)
+        else:
+            stop = model_server_stop_retry_override
+
+        _wait_for_model_server(urls.predict_url, stop)
 
 
 def _prepare_secrets_mount_dir() -> Path:
@@ -1058,8 +1096,9 @@ def _find_example_by_name(examples: List[Example], example_name: str) -> Optiona
     return None
 
 
-def _get_url_from_container(container) -> str:
-    return get_urls_from_container(container)[INFERENCE_SERVER_PORT][0]
+def get_docker_urls(container: Union[str, "Container"]) -> DockerURLs:
+    base_url = get_urls_from_container(container)[INFERENCE_SERVER_PORT][0]
+    return DockerURLs(base_url)
 
 
 def _create_rand_dir_in_dot_truss(subdir: str) -> Path:
diff --git a/truss/util/docker.py b/truss/util/docker.py
index dc00c664d..44d957082 100644
--- a/truss/util/docker.py
+++ b/truss/util/docker.py
@@ -1,6 +1,6 @@
 import enum
 import logging
-from typing import TYPE_CHECKING, Any, Dict, List
+from typing import TYPE_CHECKING, Any, Dict, List, Union
 
 if TYPE_CHECKING:
     from python_on_whales.components.container.cli_wrapper import Container
@@ -24,7 +24,7 @@ def client():
         return Docker._client
 
 
-def get_containers(labels: Dict, all: bool = False):
+def get_containers(labels: Dict, all: bool = False) -> list["Container"]:
     """Gets containers given labels."""
     return Docker.client().container.list(
         filters=_create_label_filters(labels), all=all
@@ -36,14 +36,19 @@ def get_images(labels: Dict):
     return Docker.client().image.list(filters=_create_label_filters(labels))
 
 
-def get_urls_from_container(container_details) -> Dict[int, List[str]]:
+def get_urls_from_container(container: Union[str, "Container"]) -> Dict[int, List[str]]:
     """Gets url where docker container is hosted."""
+    if isinstance(container, str):
+        container_inst = Docker.client().container.inspect(container)
+    else:
+        container_inst = container
+
     if (
-        container_details.network_settings is None
-        or container_details.network_settings.ports is None
+        container_inst.network_settings is None
+        or container_inst.network_settings.ports is None
     ):
         return {}
-    ports = container_details.network_settings.ports
+    ports = container_inst.network_settings.ports
 
     def parse_port(port_protocol) -> int:
         return int(port_protocol.split("/")[0])
@@ -74,6 +79,7 @@ def kill_containers(labels: Dict[str, bool]) -> None:
     containers = get_containers(labels)
     for container in containers:
         container_labels = container.config.labels
+        assert container_labels
         if TRUSS_DIR in container_labels:
             truss_dir = container_labels[TRUSS_DIR]
             logging.info(f"Killing Container: {container.id} for {truss_dir}")


Reviews:
user: login: michaelfeil
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T19:18:50Z
user: login: michaelfeil
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T19:19:20Z
user: login: michaelfeil
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T19:19:26Z
user: login: marius-baseten
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T18:55:57Z
user: login: marius-baseten
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T18:56:56Z
user: login: michaelfeil
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T21:25:44Z
user: login: marius-baseten
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T00:56:20Z

Review comments:
diff_hunk: @@ -1,2 +1,2 @@
-python 3.9.21
path: .tool-versions
user: login: michaelfeil
body: why bump here?
created_at: 2025-04-18T19:18:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -71,8 +71,7 @@ truss-docker-build-setup = "truss.contexts.docker_build_setup:docker_build_setup
 #  isolated.
 [tool.poetry.dependencies]
 # "base" dependencies.
-# "When using chains, 3.9 will be required at runtime, but other truss functionality works with 3.8.
-python = ">=3.9,<3.13"
+python = ">=3.9,<=3.13.3"
path: pyproject.toml
user: login: michaelfeil
body: `<=3.13.3`? Or `<3.14` does not work?
created_at: 2025-04-18T19:19:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,2 +1,2 @@
-python 3.9.21
path: .tool-versions
user: login: marius-baseten
body: So the dev env is set up with the latest python version we support. Why should we dev in an older one?
created_at: 2025-04-21T18:55:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -71,8 +71,7 @@ truss-docker-build-setup = "truss.contexts.docker_build_setup:docker_build_setup
 #  isolated.
 [tool.poetry.dependencies]
 # "base" dependencies.
-# "When using chains, 3.9 will be required at runtime, but other truss functionality works with 3.8.
-python = ">=3.9,<3.13"
+python = ">=3.9,<=3.13.3"
path: pyproject.toml
user: login: marius-baseten
body: You could do either here, but I want to automatically run integration tests with lowest and highest supported version, so I want a definite upper bound to run against as opposed to a loose constraint.
created_at: 2025-04-21T18:56:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,2 +1,2 @@
-python 3.9.21
path: .tool-versions
user: login: michaelfeil
body: would also work, thought we develop with the oldest to catch all compat issues (e.g. `maybe_str: str | None` is supported in 3.10+)
created_at: 2025-04-21T21:25:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,2 +1,2 @@
-python 3.9.21
path: .tool-versions
user: login: marius-baseten
body: That's why this PR adds an integration test to run in the oldest version. Does this address he concern?
created_at: 2025-04-22T00:56:20Z
author_association: CONTRIBUTOR

PR comments:
user: login: linear[bot]
created_at: 2025-04-18T00:33:06Z
body: <p><a href="https://linear.app/baseten/issue/BT-14455/support-python-313-for-truss">BT-14455 Support python 3.13 for Truss</a></p>
author_association: NONE
user: login: squidarth
created_at: 2025-04-23T00:53:23Z
body: > (above points are fall out from trying to run tests with pytest-xdist, because pytest-split does not work in python 3.13 - despite clean port isolation, xdist still doesn't work and gives weird errors).

Was this the only issue with Python 3.13? I saw something weird with trying to run `poetry run truss`
author_association: COLLABORATOR