feat: add retry for blocked automation

This commit is contained in:
Brad Stein 2026-01-24 07:12:35 -03:00
parent 077736b598
commit 882a9ae513
4 changed files with 197 additions and 3 deletions

View File

@ -971,6 +971,85 @@ def register(app) -> None:
except Exception: except Exception:
return jsonify({"error": "failed to load status"}), 502 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"]) @app.route("/api/access/request/onboarding/attest", methods=["POST"])
def request_access_onboarding_attest() -> Any: def request_access_onboarding_attest() -> Any:
if not configured(): if not configured():

View File

@ -51,14 +51,12 @@ def dummy_connect(rows_by_query=None):
class AccessRequestTests(TestCase): class AccessRequestTests(TestCase):
@classmethod @classmethod
def setUpClass(cls): 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.app = create_app()
cls.client = cls.app.test_client() cls.client = cls.app.test_client()
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):
cls.schema_patch.stop() return None
def setUp(self): def setUp(self):
self.configured_patch = mock.patch.object(ar, "configured", lambda: True) self.configured_patch = mock.patch.object(ar, "configured", lambda: True)
@ -289,3 +287,40 @@ class AccessRequestTests(TestCase):
vault = payload.get("vaultwarden") or {} vault = payload.get("vaultwarden") or {}
self.assertTrue(vault.get("grandfathered")) self.assertTrue(vault.get("grandfathered"))
self.assertEqual(vault.get("recovery_email"), "alice@example.com") 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)

View File

@ -67,6 +67,16 @@
<p v-if="blocked" class="muted" style="margin-top: 10px;"> <p v-if="blocked" class="muted" style="margin-top: 10px;">
One or more automation steps failed. Fix the error above, then check again. One or more automation steps failed. Fix the error above, then check again.
</p> </p>
<div v-if="blocked" class="actions" style="margin-top: 10px;">
<button class="pill mono" type="button" :disabled="retrying" @click="retryProvisioning">
{{ retrying ? "Retrying..." : "Retry failed steps" }}
</button>
<span v-if="retryMessage" class="hint mono">{{ retryMessage }}</span>
</div>
<p v-if="blocked" class="muted" style="margin-top: 8px;">
If the error mentions rate limiting or a temporary outage, wait a few minutes and retry. If it keeps failing,
contact an admin.
</p>
</div> </div>
</div> </div>
@ -335,6 +345,8 @@ const passwordCopied = ref(false);
const usernameCopied = ref(false); const usernameCopied = ref(false);
const tasks = ref([]); const tasks = ref([]);
const blocked = ref(false); const blocked = ref(false);
const retrying = ref(false);
const retryMessage = ref("");
const keycloakPasswordRotationRequested = ref(false); const keycloakPasswordRotationRequested = ref(false);
const activeSectionId = ref("vaultwarden"); const activeSectionId = ref("vaultwarden");
const guideShots = ref({}); 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() { function togglePassword() {
revealPassword.value = !revealPassword.value; revealPassword.value = !revealPassword.value;
} }

View File

@ -176,6 +176,16 @@
<p v-if="blocked" class="muted" style="margin-top: 10px;"> <p v-if="blocked" class="muted" style="margin-top: 10px;">
One or more automation steps failed. Fix the error above, then check again. One or more automation steps failed. Fix the error above, then check again.
</p> </p>
<div v-if="blocked" class="actions" style="margin-top: 10px;">
<button class="pill mono" type="button" :disabled="retrying" @click="retryProvisioning">
{{ retrying ? "Retrying..." : "Retry failed steps" }}
</button>
<span v-if="retryMessage" class="hint mono">{{ retryMessage }}</span>
</div>
<p v-if="blocked" class="muted" style="margin-top: 8px;">
If the error mentions rate limiting or a temporary outage, wait a few minutes and retry. If it keeps failing,
contact an admin.
</p>
</div> </div>
<div <div
@ -259,6 +269,8 @@ const status = ref("");
const onboardingUrl = ref(""); const onboardingUrl = ref("");
const tasks = ref([]); const tasks = ref([]);
const blocked = ref(false); const blocked = ref(false);
const retrying = ref(false);
const retryMessage = ref("");
const resending = ref(false); const resending = ref(false);
const resendMessage = ref(""); const resendMessage = ref("");
const verifyMessage = ref(""); const verifyMessage = ref("");
@ -481,6 +493,34 @@ async function checkStatus() {
} }
} }
async function retryProvisioning() {
if (retrying.value) return;
retryMessage.value = "";
const code = statusForm.request_code.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 checkStatus();
} catch (err) {
retryMessage.value = err?.message || "Retry request failed.";
} finally {
retrying.value = false;
}
}
async function verifyFromLink(code, token) { async function verifyFromLink(code, token) {
verifying.value = true; verifying.value = true;
try { try {