From 882a9ae513699282704e07f3dc22b33660122e7a Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sat, 24 Jan 2026 07:12:35 -0300 Subject: [PATCH] feat: add retry for blocked automation --- .../atlas_portal/routes/access_requests.py | 79 +++++++++++++++++++ backend/tests/test_access_requests.py | 41 +++++++++- frontend/src/views/OnboardingView.vue | 40 ++++++++++ frontend/src/views/RequestAccessView.vue | 40 ++++++++++ 4 files changed, 197 insertions(+), 3 deletions(-) diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index 6467ade..ede55f2 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -971,6 +971,85 @@ def register(app) -> None: except Exception: return jsonify({"error": "failed to load status"}), 502 + @app.route("/api/access/request/retry", methods=["POST"]) + def request_access_retry() -> Any: + if not settings.ACCESS_REQUEST_ENABLED: + return jsonify({"error": "request access disabled"}), 503 + if not configured(): + return jsonify({"error": "server not configured"}), 503 + + ip = _client_ip() + if not rate_limit_allow( + ip, + key="access_request_retry", + limit=settings.ACCESS_REQUEST_STATUS_RATE_LIMIT, + window_sec=settings.ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC, + ): + return jsonify({"error": "rate limited"}), 429 + + payload = request.get_json(silent=True) or {} + code = (payload.get("request_code") or payload.get("code") or "").strip() + tasks = payload.get("tasks") + task_list = [task for task in tasks if isinstance(task, str) and task.strip()] if isinstance(tasks, list) else [] + if not code: + return jsonify({"error": "request_code is required"}), 400 + + if ariadne_client.enabled(): + retry_payload = {"tasks": task_list} if task_list else None + return ariadne_client.proxy( + "POST", + f"/api/access/requests/{code}/retry", + payload=retry_payload, + ) + + try: + with connect() as conn: + row = conn.execute( + "SELECT status FROM access_requests WHERE request_code = %s", + (code,), + ).fetchone() + if not row: + return jsonify({"error": "not found"}), 404 + status = row.get("status") or "" + if status not in {"accounts_building", "approved"}: + return jsonify({"error": "request not retryable"}), 409 + conn.execute( + "UPDATE access_requests SET provision_attempted_at = NULL WHERE request_code = %s", + (code,), + ) + if task_list: + conn.execute( + """ + UPDATE access_request_tasks + SET status = 'pending', + detail = 'retry requested', + updated_at = NOW() + WHERE request_code = %s + AND task = ANY(%s::text[]) + AND status = 'error' + """, + (code, task_list), + ) + else: + conn.execute( + """ + UPDATE access_request_tasks + SET status = 'pending', + detail = 'retry requested', + updated_at = NOW() + WHERE request_code = %s AND status = 'error' + """, + (code,), + ) + except Exception: + return jsonify({"error": "failed to retry request"}), 502 + + try: + provision_access_request(code) + except Exception: + pass + return jsonify({"ok": True, "status": "accounts_building"}) + @app.route("/api/access/request/onboarding/attest", methods=["POST"]) def request_access_onboarding_attest() -> Any: if not configured(): diff --git a/backend/tests/test_access_requests.py b/backend/tests/test_access_requests.py index 95d1e55..05e80c3 100644 --- a/backend/tests/test_access_requests.py +++ b/backend/tests/test_access_requests.py @@ -51,14 +51,12 @@ def dummy_connect(rows_by_query=None): class AccessRequestTests(TestCase): @classmethod def setUpClass(cls): - cls.schema_patch = mock.patch("atlas_portal.app_factory.ensure_schema", lambda: None) - cls.schema_patch.start() cls.app = create_app() cls.client = cls.app.test_client() @classmethod def tearDownClass(cls): - cls.schema_patch.stop() + return None def setUp(self): self.configured_patch = mock.patch.object(ar, "configured", lambda: True) @@ -289,3 +287,40 @@ class AccessRequestTests(TestCase): vault = payload.get("vaultwarden") or {} self.assertTrue(vault.get("grandfathered")) self.assertEqual(vault.get("recovery_email"), "alice@example.com") + + def test_retry_request_fallback_updates_tasks(self): + rows = {"SELECT status": {"status": "accounts_building"}} + conn = DummyConn(rows_by_query=rows) + + @contextmanager + def connect_override(): + yield conn + + with ( + mock.patch.object(ar.ariadne_client, "enabled", lambda: False), + mock.patch.object(ar, "connect", lambda: connect_override()), + mock.patch.object(ar, "provision_access_request", lambda *_args, **_kwargs: None), + ): + resp = self.client.post( + "/api/access/request/retry", + data=json.dumps({"request_code": "alice~CODE123"}), + content_type="application/json", + ) + data = resp.get_json() + self.assertEqual(resp.status_code, 200) + self.assertTrue(data.get("ok")) + self.assertTrue(any("provision_attempted_at" in query for query, _params in conn.executed)) + + def test_retry_request_rejects_non_retryable(self): + rows = {"SELECT status": {"status": "ready"}} + + with ( + mock.patch.object(ar.ariadne_client, "enabled", lambda: False), + mock.patch.object(ar, "connect", lambda: dummy_connect(rows)), + ): + resp = self.client.post( + "/api/access/request/retry", + data=json.dumps({"request_code": "alice~CODE123"}), + content_type="application/json", + ) + self.assertEqual(resp.status_code, 409) diff --git a/frontend/src/views/OnboardingView.vue b/frontend/src/views/OnboardingView.vue index 821c297..09eb7b4 100644 --- a/frontend/src/views/OnboardingView.vue +++ b/frontend/src/views/OnboardingView.vue @@ -67,6 +67,16 @@

One or more automation steps failed. Fix the error above, then check again.

+
+ + {{ retryMessage }} +
+

+ If the error mentions rate limiting or a temporary outage, wait a few minutes and retry. If it keeps failing, + contact an admin. +

@@ -335,6 +345,8 @@ const passwordCopied = ref(false); const usernameCopied = ref(false); const tasks = ref([]); const blocked = ref(false); +const retrying = ref(false); +const retryMessage = ref(""); const keycloakPasswordRotationRequested = ref(false); const activeSectionId = ref("vaultwarden"); const guideShots = ref({}); @@ -938,6 +950,34 @@ async function check() { } } +async function retryProvisioning() { + if (retrying.value) return; + retryMessage.value = ""; + const code = requestCode.value.trim(); + if (!code) return; + retrying.value = true; + try { + const retryTasks = tasks.value + .filter((item) => item.status === "error") + .map((item) => item.task) + .filter(Boolean); + const resp = await fetch("/api/access/request/retry", { + method: "POST", + headers: { "Content-Type": "application/json" }, + cache: "no-store", + body: JSON.stringify({ request_code: code, tasks: retryTasks }), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`); + retryMessage.value = "Retry requested. Check again in a moment."; + await check(); + } catch (err) { + retryMessage.value = err?.message || "Retry request failed."; + } finally { + retrying.value = false; + } +} + function togglePassword() { revealPassword.value = !revealPassword.value; } diff --git a/frontend/src/views/RequestAccessView.vue b/frontend/src/views/RequestAccessView.vue index d218ddb..453e452 100644 --- a/frontend/src/views/RequestAccessView.vue +++ b/frontend/src/views/RequestAccessView.vue @@ -176,6 +176,16 @@

One or more automation steps failed. Fix the error above, then check again.

+
+ + {{ retryMessage }} +
+

+ If the error mentions rate limiting or a temporary outage, wait a few minutes and retry. If it keeps failing, + contact an admin. +

item.status === "error") + .map((item) => item.task) + .filter(Boolean); + const resp = await fetch("/api/access/request/retry", { + method: "POST", + headers: { "Content-Type": "application/json" }, + cache: "no-store", + body: JSON.stringify({ request_code: code, tasks: retryTasks }), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`); + retryMessage.value = "Retry requested. Check again in a moment."; + await checkStatus(); + } catch (err) { + retryMessage.value = err?.message || "Retry request failed."; + } finally { + retrying.value = false; + } +} + async function verifyFromLink(code, token) { verifying.value = true; try {