servicekit 2.0.2__tar.gz → 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {servicekit-2.0.2 → servicekit-3.0.0}/PKG-INFO +5 -6
  2. {servicekit-2.0.2 → servicekit-3.0.0}/README.md +1 -1
  3. {servicekit-2.0.2 → servicekit-3.0.0}/pyproject.toml +4 -5
  4. {servicekit-2.0.2 → servicekit-3.0.0}/pyproject.toml.orig +4 -5
  5. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/monitoring.py +4 -17
  6. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/service_builder.py +139 -41
  7. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/utilities.py +9 -7
  8. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/__init__.py +0 -0
  9. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/alembic/env.py +0 -0
  10. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/alembic/script.py.mako +0 -0
  11. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/alembic/versions/20251010_0927_4d869b5fb06e_initial_schema.py +0 -0
  12. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/__init__.py +0 -0
  13. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/app.py +0 -0
  14. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/apps/landing/index.html +0 -0
  15. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/apps/landing/manifest.json +0 -0
  16. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/auth.py +0 -0
  17. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/crud.py +0 -0
  18. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/dependencies.py +0 -0
  19. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/middleware.py +0 -0
  20. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/pagination.py +0 -0
  21. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/registration.py +0 -0
  22. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/router.py +0 -0
  23. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/routers/__init__.py +0 -0
  24. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/routers/health.py +0 -0
  25. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/routers/job.py +0 -0
  26. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/routers/metrics.py +0 -0
  27. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/routers/system.py +0 -0
  28. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/api/sse.py +0 -0
  29. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/database.py +0 -0
  30. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/exceptions.py +0 -0
  31. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/gunicorn.conf.py +0 -0
  32. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/logging.py +0 -0
  33. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/manager.py +0 -0
  34. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/models.py +0 -0
  35. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/py.typed +0 -0
  36. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/repository.py +0 -0
  37. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/scheduler.py +0 -0
  38. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/schemas.py +0 -0
  39. {servicekit-2.0.2 → servicekit-3.0.0}/src/servicekit/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: servicekit
3
- Version: 2.0.2
3
+ Version: 3.0.0
4
4
  Summary: Async SQLAlchemy framework with FastAPI integration - reusable foundation for building data services
5
5
  Keywords: fastapi,sqlalchemy,async,database,rest-api,crud,framework
6
6
  Author: Morten Hansen
@@ -15,13 +15,12 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
15
  Classifier: Topic :: Database
16
16
  Requires-Dist: aiosqlite>=0.21.0
17
17
  Requires-Dist: alembic>=1.17.0
18
- Requires-Dist: fastapi[standard]>=0.136.3
18
+ Requires-Dist: fastapi[standard]>=0.142.2
19
19
  Requires-Dist: gunicorn>=23.0.0
20
- Requires-Dist: opentelemetry-api>=1.37.0
20
+ Requires-Dist: opentelemetry-api>=1.44.0
21
21
  Requires-Dist: opentelemetry-exporter-prometheus>=0.58b0
22
- Requires-Dist: opentelemetry-instrumentation-fastapi>=0.58b0
23
22
  Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.58b0
24
- Requires-Dist: opentelemetry-sdk>=1.37.0
23
+ Requires-Dist: opentelemetry-sdk>=1.44.0
25
24
  Requires-Dist: pydantic>=2.12.0
26
25
  Requires-Dist: python-ulid>=3.1.0
27
26
  Requires-Dist: sqlalchemy[asyncio]>=2.0.43
@@ -115,7 +114,7 @@ app = (
115
114
  .with_database(url) # Database configuration
116
115
  .with_jobs(max_concurrency=10) # Job scheduler
117
116
  .with_auth() # API key authentication
118
- .with_monitoring() # Prometheus metrics
117
+ .with_monitoring() # Prometheus metrics (on by default)
119
118
  .with_app("./webapp") # Static web app
120
119
  .include_router(custom_router) # Custom routes
121
120
  .build()
@@ -79,7 +79,7 @@ app = (
79
79
  .with_database(url) # Database configuration
80
80
  .with_jobs(max_concurrency=10) # Job scheduler
81
81
  .with_auth() # API key authentication
82
- .with_monitoring() # Prometheus metrics
82
+ .with_monitoring() # Prometheus metrics (on by default)
83
83
  .with_app("./webapp") # Static web app
84
84
  .include_router(custom_router) # Custom routes
85
85
  .build()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "servicekit"
3
- version = "2.0.2"
3
+ version = "3.0.0"
4
4
  description = "Async SQLAlchemy framework with FastAPI integration - reusable foundation for building data services"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13,<3.14"
@@ -25,13 +25,12 @@ classifiers = [
25
25
  dependencies = [
26
26
  "aiosqlite>=0.21.0",
27
27
  "alembic>=1.17.0",
28
- "fastapi[standard]>=0.136.3",
28
+ "fastapi[standard]>=0.142.2",
29
29
  "gunicorn>=23.0.0",
30
- "opentelemetry-api>=1.37.0",
30
+ "opentelemetry-api>=1.44.0",
31
31
  "opentelemetry-exporter-prometheus>=0.58b0",
32
- "opentelemetry-instrumentation-fastapi>=0.58b0",
33
32
  "opentelemetry-instrumentation-sqlalchemy>=0.58b0",
34
- "opentelemetry-sdk>=1.37.0",
33
+ "opentelemetry-sdk>=1.44.0",
35
34
  "pydantic>=2.12.0",
36
35
  "python-ulid>=3.1.0",
37
36
  "sqlalchemy[asyncio]>=2.0.43",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "servicekit"
3
- version = "2.0.2"
3
+ version = "3.0.0"
4
4
  description = "Async SQLAlchemy framework with FastAPI integration - reusable foundation for building data services"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Morten Hansen", email = "morten@dhis2.org" }]
@@ -27,13 +27,12 @@ classifiers = [
27
27
  dependencies = [
28
28
  "aiosqlite>=0.21.0",
29
29
  "alembic>=1.17.0",
30
- "fastapi[standard]>=0.136.3",
30
+ "fastapi[standard]>=0.142.2",
31
31
  "gunicorn>=23.0.0",
32
- "opentelemetry-api>=1.37.0",
32
+ "opentelemetry-api>=1.44.0",
33
33
  "opentelemetry-exporter-prometheus>=0.58b0",
34
- "opentelemetry-instrumentation-fastapi>=0.58b0",
35
34
  "opentelemetry-instrumentation-sqlalchemy>=0.58b0",
36
- "opentelemetry-sdk>=1.37.0",
35
+ "opentelemetry-sdk>=1.44.0",
37
36
  "pydantic>=2.12.0",
38
37
  "python-ulid>=3.1.0",
39
38
  "sqlalchemy[asyncio]>=2.0.43",
@@ -1,9 +1,8 @@
1
- """OpenTelemetry monitoring setup with auto-instrumentation."""
1
+ """OpenTelemetry monitoring setup: Prometheus metrics, SQLAlchemy instrumentation, FastAPI OpenTelemetry."""
2
2
 
3
3
  from fastapi import FastAPI
4
4
  from opentelemetry import metrics
5
5
  from opentelemetry.exporter.prometheus import PrometheusMetricReader
6
- from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
7
6
  from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
8
7
  from opentelemetry.sdk.metrics import MeterProvider
9
8
  from opentelemetry.sdk.resources import Resource
@@ -28,7 +27,7 @@ def setup_monitoring(
28
27
  service_name: str | None = None,
29
28
  enable_traces: bool = False,
30
29
  ) -> PrometheusMetricReader:
31
- """Setup OpenTelemetry with FastAPI and SQLAlchemy auto-instrumentation."""
30
+ """Setup the global Prometheus meter provider and SQLAlchemy instrumentation; FastAPI OpenTelemetry records HTTP."""
32
31
  global _meter_provider_initialized, _sqlalchemy_instrumented, _process_collector_registered, _metric_reader
33
32
 
34
33
  # Use app title as service name if not provided
@@ -54,11 +53,6 @@ def setup_monitoring(
54
53
  # Already registered
55
54
  pass
56
55
 
57
- # Auto-instrument FastAPI - check if already instrumented
58
- instrumentor = FastAPIInstrumentor()
59
- if not instrumentor.is_instrumented_by_opentelemetry:
60
- instrumentor.instrument_app(app)
61
-
62
56
  # Auto-instrument SQLAlchemy - only once globally
63
57
  if not _sqlalchemy_instrumented:
64
58
  try:
@@ -86,19 +80,12 @@ def setup_monitoring(
86
80
 
87
81
 
88
82
  def teardown_monitoring() -> None:
89
- """Teardown OpenTelemetry instrumentation and reset module state."""
90
- global _meter_provider_initialized, _sqlalchemy_instrumented, _process_collector_registered, _metric_reader
83
+ """Teardown SQLAlchemy instrumentation; the global meter provider cannot be replaced, so its reader stays."""
84
+ global _sqlalchemy_instrumented
91
85
 
92
- _meter_provider_initialized = False
93
86
  _sqlalchemy_instrumented = False
94
- _process_collector_registered = False
95
- _metric_reader = None
96
87
 
97
88
  try:
98
- # Uninstrument FastAPI
99
- FastAPIInstrumentor().uninstrument()
100
-
101
- # Uninstrument SQLAlchemy
102
89
  SQLAlchemyInstrumentor().uninstrument()
103
90
 
104
91
  logger.info("monitoring.disabled")
@@ -5,16 +5,20 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import os
7
7
  import re
8
+ import secrets
8
9
  import signal
9
10
  import sys
11
+ from collections.abc import MutableMapping, Sequence
10
12
  from contextlib import asynccontextmanager
11
13
  from dataclasses import dataclass
12
14
  from pathlib import Path
13
15
  from typing import Any, AsyncContextManager, AsyncGenerator, Awaitable, Callable, Self
14
16
 
15
17
  from fastapi import APIRouter, FastAPI
18
+ from fastapi.telemetry import TelemetryConfig
16
19
  from pydantic import BaseModel, ConfigDict, field_validator
17
20
  from sqlalchemy import text
21
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
18
22
 
19
23
  from servicekit import Database, SqliteDatabase
20
24
  from servicekit.logging import configure_logging, get_logger
@@ -34,6 +38,10 @@ type LifecycleHook = Callable[[FastAPI], Awaitable[None]]
34
38
  type DependencyOverride = Callable[..., object]
35
39
  type LifespanFactory = Callable[[FastAPI], AsyncContextManager[None]]
36
40
 
41
+ # Header the readiness probe sends and expects echoed back, so it only accepts its own app
42
+ _READINESS_TOKEN_HEADER = "x-servicekit-readiness-token"
43
+ _DEFAULT_LOCAL_PORT = 8000
44
+
37
45
 
38
46
  @dataclass(frozen=True)
39
47
  class _HealthOptions:
@@ -107,6 +115,8 @@ class _RegistrationOptions:
107
115
  service_key: str | None
108
116
  service_key_env: str
109
117
  re_register_grace_period: float
118
+ local_port: int | None = None
119
+ local_port_env: str = "PORT"
110
120
 
111
121
 
112
122
  class ServiceInfo(BaseModel):
@@ -159,7 +169,12 @@ class BaseServiceBuilder:
159
169
  self._system_options: _SystemOptions | None = None
160
170
  self._job_options: JobOptions | None = None
161
171
  self._auth_options: _AuthOptions | None = None
162
- self._monitoring_options: _MonitoringOptions | None = None
172
+ self._monitoring_options: _MonitoringOptions | None = _MonitoringOptions(
173
+ prefix="/metrics",
174
+ tags=["Observability"],
175
+ service_name=None,
176
+ enable_traces=False,
177
+ )
163
178
  self._registration_options: _RegistrationOptions | None = None
164
179
  self._app_configs: list[App] = []
165
180
  self._custom_routers: list[APIRouter] = []
@@ -307,13 +322,17 @@ class BaseServiceBuilder:
307
322
 
308
323
  def with_monitoring(
309
324
  self,
325
+ enabled: bool = True,
310
326
  *,
311
327
  prefix: str = "/metrics",
312
328
  tags: list[str] | None = None,
313
329
  service_name: str | None = None,
314
330
  enable_traces: bool = False,
315
331
  ) -> Self:
316
- """Enable OpenTelemetry monitoring with Prometheus endpoint and auto-instrumentation."""
332
+ """Configure OpenTelemetry monitoring (enabled by default); pass enabled=False to turn it off."""
333
+ if not enabled:
334
+ self._monitoring_options = None
335
+ return self
317
336
  self._monitoring_options = _MonitoringOptions(
318
337
  prefix=prefix,
319
338
  tags=list(tags) if tags is not None else ["Observability"],
@@ -341,8 +360,15 @@ class BaseServiceBuilder:
341
360
  service_key: str | None = None,
342
361
  service_key_env: str = "SERVICEKIT_REGISTRATION_KEY",
343
362
  re_register_grace_period: float = 30.0,
363
+ local_port: int | None = None,
364
+ local_port_env: str = "PORT",
344
365
  ) -> Self:
345
- """Enable service registration with orchestrator for service discovery."""
366
+ """Enable service registration with orchestrator for service discovery.
367
+
368
+ ``host``/``port`` (or ``host_env``/``port_env``) form the address advertised to the orchestrator.
369
+ ``local_port`` (or ``local_port_env``) is the port the app binds locally, probed for readiness
370
+ before registering; set it when the advertised port differs from the bind port.
371
+ """
346
372
  self._registration_options = _RegistrationOptions(
347
373
  orchestrator_url=orchestrator_url,
348
374
  host=host,
@@ -360,6 +386,8 @@ class BaseServiceBuilder:
360
386
  service_key=service_key,
361
387
  service_key_env=service_key_env,
362
388
  re_register_grace_period=re_register_grace_period,
389
+ local_port=local_port,
390
+ local_port_env=local_port_env,
363
391
  )
364
392
  return self
365
393
 
@@ -408,6 +436,7 @@ class BaseServiceBuilder:
408
436
  description=self._app_description,
409
437
  version=self._version,
410
438
  lifespan=lifespan,
439
+ telemetry=self._build_telemetry_config(),
411
440
  )
412
441
  app.state.database_url = self._database_url
413
442
 
@@ -534,8 +563,26 @@ class BaseServiceBuilder:
534
563
  for dependency, override in self._dependency_overrides.items():
535
564
  app.dependency_overrides[dependency] = override
536
565
 
566
+ # Last, so it wraps any build_middleware_stack patch made above
567
+ if self._registration_options is not None:
568
+ app.state.readiness_token = secrets.token_hex(16)
569
+ app.build_middleware_stack = _wrap_with_readiness_token(app) # type: ignore[method-assign]
570
+
537
571
  return app
538
572
 
573
+ def _build_telemetry_config(self) -> TelemetryConfig:
574
+ """Build FastAPI OpenTelemetry settings: skip metrics scrapes when enabled, turn everything off when not."""
575
+ if self._monitoring_options is None:
576
+ return {"tracing": False, "metrics": False, "logs": False, "auto_configure": False}
577
+ metrics_prefix = self._monitoring_options.prefix
578
+
579
+ def exclude_metrics_scrape(scope: MutableMapping[str, Any]) -> bool:
580
+ """Skip telemetry for requests to the metrics endpoint."""
581
+ path = str(scope.get("path", ""))
582
+ return path == metrics_prefix or path.startswith(f"{metrics_prefix}/")
583
+
584
+ return {"exclude": exclude_metrics_scrape}
585
+
539
586
  # --------------------------------------------------------------------- Extension points
540
587
 
541
588
  def _validate_module_configuration(self) -> None:
@@ -882,49 +929,99 @@ class BaseServiceBuilder:
882
929
  return cls(info=info, **kwargs).build()
883
930
 
884
931
 
885
- def _resolve_port(options: _RegistrationOptions) -> int:
886
- """Resolve port from options or environment, matching register_service logic."""
887
- if options.port is not None:
888
- return options.port
889
- port_str = os.getenv(options.port_env)
890
- if port_str:
891
- try:
892
- return int(port_str)
893
- except ValueError:
894
- return 8000
895
- return 8000
932
+ class _ReadinessTokenMiddleware:
933
+ """Echo the readiness token header on responses to requests that present the matching token."""
934
+
935
+ def __init__(self, app: ASGIApp, *, token: str) -> None:
936
+ """Wrap the ASGI app with the per-instance readiness token."""
937
+ self.app = app
938
+ self.token = token
939
+
940
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
941
+ """Pass the request through, tagging the response when the probe token matches."""
942
+ if scope["type"] != "http" or not self._has_token(scope):
943
+ await self.app(scope, receive, send)
944
+ return
945
+
946
+ async def send_with_token(message: Message) -> None:
947
+ """Append the token header to the response start message."""
948
+ if message["type"] == "http.response.start":
949
+ headers = list(message.get("headers", []))
950
+ headers.append((_READINESS_TOKEN_HEADER.encode("latin-1"), self.token.encode("latin-1")))
951
+ message = {**message, "headers": headers}
952
+ await send(message)
953
+
954
+ await self.app(scope, receive, send_with_token)
955
+
956
+ def _has_token(self, scope: Scope) -> bool:
957
+ """Return whether the request carries this instance's readiness token."""
958
+ expected = (_READINESS_TOKEN_HEADER.encode("latin-1"), self.token.encode("latin-1"))
959
+ return any((name.lower(), value) == expected for name, value in scope.get("headers", []))
960
+
961
+
962
+ def _wrap_with_readiness_token(app: FastAPI) -> Callable[[], ASGIApp]:
963
+ """Wrap the app's middleware stack builder so readiness token tagging is always the outermost layer."""
964
+ build_middleware_stack = app.build_middleware_stack
965
+
966
+ def build_with_readiness_token() -> ASGIApp:
967
+ """Build the regular stack, including middleware added after build(), inside the token middleware."""
968
+ return _ReadinessTokenMiddleware(build_middleware_stack(), token=app.state.readiness_token)
969
+
970
+ return build_with_readiness_token
971
+
972
+
973
+ def _parse_port(value: str | None) -> int | None:
974
+ """Parse a port from an environment value, returning None when unset or invalid."""
975
+ if not value:
976
+ return None
977
+ try:
978
+ return int(value)
979
+ except ValueError:
980
+ return None
981
+
982
+
983
+ def _local_port_candidates(options: _RegistrationOptions) -> list[int]:
984
+ """Resolve the local ports to probe for readiness, most specific first."""
985
+ if options.local_port is not None:
986
+ return [options.local_port]
987
+ advertised_port = options.port if options.port is not None else _parse_port(os.getenv(options.port_env))
988
+ candidates = [_parse_port(os.getenv(options.local_port_env)), advertised_port, _DEFAULT_LOCAL_PORT]
989
+ return list(dict.fromkeys(port for port in candidates if port is not None))
896
990
 
897
991
 
898
992
  async def _wait_until_ready(
899
- port: int, *, health_path: str | None = None, poll_interval: float = 0.5, timeout: float = 30.0
900
- ) -> bool:
901
- """Poll the local app until it is serving requests."""
993
+ ports: Sequence[int],
994
+ *,
995
+ token: str,
996
+ health_path: str | None = None,
997
+ poll_interval: float = 0.5,
998
+ timeout: float = 30.0,
999
+ ) -> int | None:
1000
+ """Poll the candidate local ports until this app serves requests, returning the ready port or None."""
902
1001
  import httpx
903
1002
 
904
- if health_path:
905
- url = f"http://127.0.0.1:{port}{health_path}"
906
- check_kind = "health"
907
- else:
908
- url = f"http://127.0.0.1:{port}/"
909
- check_kind = "tcp"
1003
+ path = health_path or "/"
1004
+ check_kind = "health" if health_path else "tcp"
1005
+ headers = {_READINESS_TOKEN_HEADER: token}
910
1006
 
911
1007
  deadline = asyncio.get_event_loop().time() + timeout
912
1008
  while asyncio.get_event_loop().time() < deadline:
913
- try:
914
- async with httpx.AsyncClient() as client:
915
- response = await client.get(url, timeout=2.0)
916
- if health_path:
917
- if response.status_code == 200:
918
- return True
919
- else:
920
- # Any response means the server is accepting connections
921
- return True
922
- except Exception:
923
- pass
1009
+ for port in ports:
1010
+ try:
1011
+ async with httpx.AsyncClient() as client:
1012
+ response = await client.get(f"http://127.0.0.1:{port}{path}", headers=headers, timeout=2.0)
1013
+ except Exception:
1014
+ continue
1015
+ # Something else listening on a candidate port (e.g. the orchestrator itself) is not us
1016
+ if response.headers.get(_READINESS_TOKEN_HEADER) != token:
1017
+ continue
1018
+ # In tcp mode any response means the server is accepting connections
1019
+ if not health_path or response.status_code == 200:
1020
+ return port
924
1021
  await asyncio.sleep(poll_interval)
925
1022
 
926
- logger.warning("registration.readiness_timeout", port=port, check=check_kind, timeout=timeout)
927
- return False
1023
+ logger.warning("registration.readiness_timeout", ports=list(ports), check=check_kind, timeout=timeout)
1024
+ return None
928
1025
 
929
1026
 
930
1027
  def _fail_registration(app: FastAPI, reason: str) -> None:
@@ -992,18 +1089,19 @@ async def _register_after_ready(
992
1089
  options: _RegistrationOptions, info: BaseModel, app: FastAPI, health_path: str | None
993
1090
  ) -> None:
994
1091
  """Wait for the app to be ready, then register with the orchestrator."""
995
- port = _resolve_port(options)
996
- ready = await _wait_until_ready(port, health_path=health_path)
997
- if not ready:
1092
+ ports = _local_port_candidates(options)
1093
+ ready_port = await _wait_until_ready(ports, token=app.state.readiness_token, health_path=health_path)
1094
+ if ready_port is None:
998
1095
  if options.fail_on_error:
999
- _fail_registration(app, f"App on port {port} never became ready, cannot register")
1096
+ _fail_registration(app, f"App never became ready on local ports {ports}, cannot register")
1000
1097
  else:
1001
1098
  logger.error(
1002
1099
  "registration.aborted",
1003
- port=port,
1100
+ ports=ports,
1004
1101
  message="App never became ready, skipping registration",
1005
1102
  )
1006
1103
  return
1104
+ logger.debug("registration.ready", port=ready_port)
1007
1105
 
1008
1106
  # Shield registration from cancellation so that if the POST succeeds,
1009
1107
  # app.state.registration_info is always written before the task exits.
@@ -41,9 +41,10 @@ def run_app(
41
41
  Args:
42
42
  app: FastAPI app instance OR string "module:app" path
43
43
  host: Server host (default: "127.0.0.1", env: HOST)
44
- port: Server port (default: 8000, env: PORT). The resolved value is also
45
- exported as SERVICEKIT_PORT (unless already set) so service
46
- registration advertises and probes the same port uvicorn binds.
44
+ port: Server port (default: 8000, env: PORT). The resolved value is
45
+ exported as PORT so service registration probes the port uvicorn
46
+ binds, and as SERVICEKIT_PORT (unless already set) so it is also
47
+ the advertised port by default.
47
48
  workers: Number of worker processes (default: 1, env: WORKERS)
48
49
  reload: Enable auto-reload (default: True for string, False for instance)
49
50
  log_level: Logging level (default: from LOG_LEVEL env var or "info")
@@ -71,10 +72,11 @@ def run_app(
71
72
  reload = False
72
73
 
73
74
  # Make the bind port visible to service registration, which runs inside the
74
- # app lifespan and otherwise resolves its own port (SERVICEKIT_PORT, else
75
- # 8000) with no knowledge of where uvicorn actually bound. setdefault means an
76
- # explicitly set SERVICEKIT_PORT (e.g. an externally advertised port behind a
77
- # proxy) still wins.
75
+ # app lifespan with no knowledge of where uvicorn actually bound. PORT is the
76
+ # local port its readiness probe tries first. SERVICEKIT_PORT is the port
77
+ # advertised to the orchestrator; setdefault means an explicitly set value
78
+ # (e.g. a published host port or a port behind a proxy) still wins.
79
+ os.environ["PORT"] = str(resolved_port)
78
80
  os.environ.setdefault("SERVICEKIT_PORT", str(resolved_port))
79
81
 
80
82
  uvicorn.run(