diff --git a/api/src/learn_to_cloud/routes/htmx_routes.py b/api/src/learn_to_cloud/routes/htmx_routes.py index 2301c17..3a4971b 100644 --- a/api/src/learn_to_cloud/routes/htmx_routes.py +++ b/api/src/learn_to_cloud/routes/htmx_routes.py @@ -100,6 +100,10 @@ "Verification could not be started. This attempt was not counted — " "please try again." ) +_DURABLE_TERMINAL_ERROR_MESSAGE = ( + "Verification failed because the verification service hit an internal error. " + "This attempt was not counted. Please try again." +) _ACTIVE_DURABLE_STATUSES = {"pending", "running", "continuedasnew"} _TERMINAL_DURABLE_STATUSES = {"completed", "failed", "terminated", "canceled"} @@ -155,7 +159,7 @@ async def _delete_terminal_job( request: Request, token_data: VerificationStatusToken, status: str, -) -> None: +) -> bool: """Drop the verification_jobs row after Durable reaches terminal failure. Replaces the older ``mark_server_error`` / ``mark_cancelled`` writes: @@ -179,6 +183,7 @@ async def _delete_terminal_job( "verification.poller.delete_skipped", extra={"job_id": str(job_id), "runtime_status": status}, ) + return deleted async def _render_step_toggle( @@ -570,8 +575,24 @@ async def htmx_verification_job_status( ) if status in _DURABLE_FAILURE_STATUSES: - await _delete_terminal_job(request, token_data, status) - return HTMLResponse(_reload_verification_html()) + deleted = await _delete_terminal_job(request, token_data, status) + if not deleted: + return HTMLResponse(_reload_verification_html()) + + requirement = await get_requirement_by_slug(db, token_data.requirement_slug) + if requirement is None: + return HTMLResponse(_reload_verification_html()) + + return templates.TemplateResponse( + request, + "partials/requirement_card.html", + build_requirement_card_context( + requirement=requirement, + github_username=current_user.github_username, + server_error=True, + server_error_message=_DURABLE_TERMINAL_ERROR_MESSAGE, + ), + ) if status in _TERMINAL_DURABLE_STATUSES: return HTMLResponse(_reload_verification_html()) diff --git a/api/tests/routes/test_htmx_routes.py b/api/tests/routes/test_htmx_routes.py index 62d47ae..92fec3f 100644 --- a/api/tests/routes/test_htmx_routes.py +++ b/api/tests/routes/test_htmx_routes.py @@ -656,10 +656,12 @@ async def test_completed_status_returns_reload_trigger(self): assert isinstance(result, HTMLResponse) assert "location.reload()" in bytes(result.body).decode() - async def test_failed_status_deletes_active_job(self): + async def test_failed_status_deletes_active_job_and_renders_error( + self, + _patch_templates, + ): """Durable terminal failure deletes the row instead of marking - a server-error status. Frees the partial unique index for retry - without copying Durable's terminal state into Postgres.""" + a server-error status, then shows a retryable service error.""" request = _mock_request() mock_session = AsyncMock() request.app.state.session_maker.return_value.__aenter__.return_value = ( @@ -688,6 +690,10 @@ async def test_failed_status_deletes_active_job(self): "learn_to_cloud.routes.htmx_routes.VerificationJobRepository", autospec=True, ) as mock_repository_class, + patch( + "learn_to_cloud.routes.htmx_routes.get_requirement_by_slug", + return_value=MagicMock(), + ), ): mock_repository = mock_repository_class.return_value mock_repository.delete_active = AsyncMock(return_value=True) @@ -702,6 +708,13 @@ async def test_failed_status_deletes_active_job(self): assert isinstance(result, HTMLResponse) mock_repository.delete_active.assert_awaited_once_with(job_id) mock_session.commit.assert_awaited_once() + _, _, context = _patch_templates.TemplateResponse.call_args.args + assert context["server_error"] is True + assert ( + context["server_error_message"] + == "Verification failed because the verification service hit an internal " + "error. This attempt was not counted. Please try again." + ) async def test_canceled_status_also_deletes_active_job(self): """``Canceled`` and ``Terminated`` are handled the same way as @@ -734,6 +747,10 @@ async def test_canceled_status_also_deletes_active_job(self): "learn_to_cloud.routes.htmx_routes.VerificationJobRepository", autospec=True, ) as mock_repository_class, + patch( + "learn_to_cloud.routes.htmx_routes.get_requirement_by_slug", + return_value=MagicMock(), + ), ): mock_repository = mock_repository_class.return_value mock_repository.delete_active = AsyncMock(return_value=True)