Skip to content

Commit 3b6a716

Browse files
committed
feat(bedrock): Add deployment status polling after CreateCustomModelDeployment
Previously create_deployment() only polled for the custom model to reach Active status before calling CreateCustomModelDeployment, but did not wait for the deployment itself to become Active. This caused callers to receive a deployment ARN that was still in Creating state, requiring manual polling in user code. Add _wait_for_deployment_active() that polls get_custom_model_deployment until status reaches Active, raises RuntimeError on Failed, and times out after max_wait seconds (default 3600s, poll interval 30s). Wire it into create_deployment() so the full flow is now: 1. _wait_for_model_active (poll model creation) 2. create_custom_model_deployment (API call) 3. _wait_for_deployment_active (poll deployment creation) Gracefully skips deployment polling if the API response does not contain a customModelDeploymentArn. Unit tests (48 passing): - _wait_for_deployment_active: immediate Active, polling, Failed status, timeout - create_deployment: full model+deployment polling chain, skip polling when no ARN in response - deploy Nova chain: updated to verify deployment polling
1 parent ebe542e commit 3b6a716

2 files changed

Lines changed: 117 additions & 7 deletions

File tree

sagemaker-serve/src/sagemaker/serve/bedrock_model_builder.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -193,20 +193,21 @@ def create_deployment(
193193
"""Create a deployment for a Nova custom model.
194194
195195
Polls the model status until it becomes Active before creating the deployment,
196-
since Bedrock requires the custom model to be ready before deployment.
196+
then polls the deployment status until it becomes Active.
197197
198198
Args:
199199
model_arn: ARN of the custom model to deploy.
200200
deployment_name: Name for the deployment.
201-
poll_interval: Seconds between status checks. Defaults to 60.
202-
max_wait: Maximum seconds to wait for model to become Active. Defaults to 3600.
201+
poll_interval: Seconds between status checks. Defaults to 60 for model,
202+
30 for deployment.
203+
max_wait: Maximum seconds to wait per polling phase. Defaults to 3600.
203204
**kwargs: Additional parameters for create_custom_model_deployment.
204205
205206
Returns:
206207
Response from Bedrock create_custom_model_deployment API.
207208
208209
Raises:
209-
RuntimeError: If the model fails or times out waiting to become Active.
210+
RuntimeError: If the model or deployment fails or times out.
210211
ValueError: If model_arn is not provided.
211212
"""
212213
if not model_arn:
@@ -222,7 +223,15 @@ def create_deployment(
222223
params = {k: v for k, v in params.items() if v is not None}
223224

224225
logger.info("Creating deployment %s for model %s", deployment_name, model_arn)
225-
return self._get_bedrock_client().create_custom_model_deployment(**params)
226+
response = self._get_bedrock_client().create_custom_model_deployment(**params)
227+
228+
deployment_arn = response.get("customModelDeploymentArn")
229+
if deployment_arn:
230+
self._wait_for_deployment_active(
231+
deployment_arn, poll_interval=poll_interval, max_wait=max_wait
232+
)
233+
234+
return response
226235

227236
def _wait_for_model_active(
228237
self, model_arn: str, poll_interval: int = 60, max_wait: int = 3600
@@ -256,6 +265,40 @@ def _wait_for_model_active(
256265
f"Last status: {status}"
257266
)
258267

268+
def _wait_for_deployment_active(
269+
self, deployment_arn: str, poll_interval: int = 30, max_wait: int = 3600
270+
):
271+
"""Poll Bedrock until the custom model deployment reaches Active status.
272+
273+
Args:
274+
deployment_arn: ARN of the custom model deployment.
275+
poll_interval: Seconds between status checks. Defaults to 30.
276+
max_wait: Maximum seconds to wait. Defaults to 3600.
277+
278+
Raises:
279+
RuntimeError: If the deployment status is Failed or the wait times out.
280+
"""
281+
elapsed = 0
282+
status = None
283+
while elapsed < max_wait:
284+
resp = self._get_bedrock_client().get_custom_model_deployment(
285+
customModelDeploymentIdentifier=deployment_arn
286+
)
287+
status = resp.get("status")
288+
logger.info("Deployment status: %s (elapsed %ds)", status, elapsed)
289+
if status == "Active":
290+
return
291+
if status == "Failed":
292+
raise RuntimeError(
293+
f"Deployment {deployment_arn} failed."
294+
)
295+
time.sleep(poll_interval)
296+
elapsed += poll_interval
297+
raise RuntimeError(
298+
f"Timed out after {max_wait}s waiting for deployment {deployment_arn} to become Active. "
299+
f"Last status: {status}"
300+
)
301+
259302
def _fetch_model_package(self) -> Optional[ModelPackage]:
260303
"""Fetch the ModelPackage from the provided model.
261304

sagemaker-serve/tests/unit/test_bedrock_model_builder.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,27 +377,44 @@ def test_timeout_raises(self):
377377

378378

379379
class TestCreateDeployment:
380-
def test_polls_then_creates(self):
380+
def test_polls_model_then_creates_then_polls_deployment(self):
381381
b = _builder()
382382
b._bedrock_client = Mock()
383383
b._bedrock_client.get_custom_model.return_value = {"modelStatus": "Active"}
384384
b._bedrock_client.create_custom_model_deployment.return_value = {
385385
"customModelDeploymentArn": "arn:dep"
386386
}
387+
b._bedrock_client.get_custom_model_deployment.return_value = {"status": "Active"}
388+
387389
result = b.create_deployment(model_arn="arn:model", deployment_name="dep")
390+
388391
b._bedrock_client.get_custom_model.assert_called_once()
389392
b._bedrock_client.create_custom_model_deployment.assert_called_once()
393+
b._bedrock_client.get_custom_model_deployment.assert_called_once()
390394
assert result["customModelDeploymentArn"] == "arn:dep"
391395

392396
def test_passes_extra_kwargs(self):
393397
b = _builder()
394398
b._bedrock_client = Mock()
395399
b._bedrock_client.get_custom_model.return_value = {"modelStatus": "Active"}
396-
b._bedrock_client.create_custom_model_deployment.return_value = {}
400+
b._bedrock_client.create_custom_model_deployment.return_value = {
401+
"customModelDeploymentArn": "arn:dep"
402+
}
403+
b._bedrock_client.get_custom_model_deployment.return_value = {"status": "Active"}
404+
397405
b.create_deployment(model_arn="arn:model", deployment_name="d", commitmentDuration="ONE_MONTH")
398406
kw = b._bedrock_client.create_custom_model_deployment.call_args[1]
399407
assert kw["commitmentDuration"] == "ONE_MONTH"
400408

409+
def test_skips_deployment_polling_when_no_arn_in_response(self):
410+
b = _builder()
411+
b._bedrock_client = Mock()
412+
b._bedrock_client.get_custom_model.return_value = {"modelStatus": "Active"}
413+
b._bedrock_client.create_custom_model_deployment.return_value = {}
414+
415+
b.create_deployment(model_arn="arn:model", deployment_name="d")
416+
b._bedrock_client.get_custom_model_deployment.assert_not_called()
417+
401418
def test_empty_model_arn_raises(self):
402419
with pytest.raises(ValueError, match="model_arn is required"):
403420
_builder().create_deployment(model_arn="", deployment_name="d")
@@ -407,6 +424,47 @@ def test_none_model_arn_raises(self):
407424
_builder().create_deployment(model_arn=None, deployment_name="d")
408425

409426

427+
# ── _wait_for_deployment_active ─────────────────────────────────────────────
428+
429+
430+
class TestWaitForDeploymentActive:
431+
def test_immediate_active(self):
432+
b = _builder()
433+
b._bedrock_client = Mock()
434+
b._bedrock_client.get_custom_model_deployment.return_value = {"status": "Active"}
435+
b._wait_for_deployment_active("arn:dep")
436+
b._bedrock_client.get_custom_model_deployment.assert_called_once_with(
437+
customModelDeploymentIdentifier="arn:dep"
438+
)
439+
440+
def test_polls_then_active(self):
441+
b = _builder()
442+
b._bedrock_client = Mock()
443+
b._bedrock_client.get_custom_model_deployment.side_effect = [
444+
{"status": "Creating"},
445+
{"status": "Creating"},
446+
{"status": "Active"},
447+
]
448+
with patch(f"{MODULE}.time.sleep"):
449+
b._wait_for_deployment_active("arn:dep", poll_interval=1, max_wait=10)
450+
assert b._bedrock_client.get_custom_model_deployment.call_count == 3
451+
452+
def test_failed_raises(self):
453+
b = _builder()
454+
b._bedrock_client = Mock()
455+
b._bedrock_client.get_custom_model_deployment.return_value = {"status": "Failed"}
456+
with pytest.raises(RuntimeError, match="failed"):
457+
b._wait_for_deployment_active("arn:dep")
458+
459+
def test_timeout_raises(self):
460+
b = _builder()
461+
b._bedrock_client = Mock()
462+
b._bedrock_client.get_custom_model_deployment.return_value = {"status": "Creating"}
463+
with patch(f"{MODULE}.time.sleep"):
464+
with pytest.raises(RuntimeError, match="Timed out"):
465+
b._wait_for_deployment_active("arn:dep", poll_interval=1, max_wait=2)
466+
467+
410468
# ── deploy ──────────────────────────────────────────────────────────────────
411469

412470

@@ -432,10 +490,13 @@ def test_nova_full_chain(self):
432490
b._bedrock_client.create_custom_model_deployment.return_value = {
433491
"customModelDeploymentArn": "arn:dep"
434492
}
493+
b._bedrock_client.get_custom_model_deployment.return_value = {"status": "Active"}
494+
435495
result = b.deploy(custom_model_name="nova-m", role_arn="r")
436496
b._bedrock_client.create_custom_model.assert_called_once()
437497
b._bedrock_client.get_custom_model.assert_called_once()
438498
b._bedrock_client.create_custom_model_deployment.assert_called_once()
499+
b._bedrock_client.get_custom_model_deployment.assert_called_once()
439500
assert result["customModelDeploymentArn"] == "arn:dep"
440501

441502
def test_nova_via_hub_content_name(self):
@@ -449,6 +510,8 @@ def test_nova_via_hub_content_name(self):
449510
b._bedrock_client.create_custom_model_deployment.return_value = {
450511
"customModelDeploymentArn": "arn:dep"
451512
}
513+
b._bedrock_client.get_custom_model_deployment.return_value = {"status": "Active"}
514+
452515
result = b.deploy(custom_model_name="n", role_arn="r")
453516
assert result["customModelDeploymentArn"] == "arn:dep"
454517

@@ -463,6 +526,8 @@ def test_nova_default_deployment_name(self):
463526
b._bedrock_client.create_custom_model_deployment.return_value = {
464527
"customModelDeploymentArn": "dep"
465528
}
529+
b._bedrock_client.get_custom_model_deployment.return_value = {"status": "Active"}
530+
466531
b.deploy(custom_model_name="my-model", role_arn="r")
467532
kw = b._bedrock_client.create_custom_model_deployment.call_args[1]
468533
assert kw["modelDeploymentName"] == "my-model-deployment"
@@ -478,6 +543,8 @@ def test_nova_with_tags(self):
478543
b._bedrock_client.create_custom_model_deployment.return_value = {
479544
"customModelDeploymentArn": "dep"
480545
}
546+
b._bedrock_client.get_custom_model_deployment.return_value = {"status": "Active"}
547+
481548
tags = [{"Key": "env", "Value": "test"}]
482549
b.deploy(custom_model_name="m", role_arn="r", model_tags=tags)
483550
kw = b._bedrock_client.create_custom_model.call_args[1]

0 commit comments

Comments
 (0)