feat: add retry for blocked automation
This commit is contained in:
parent
077736b598
commit
882a9ae513
@ -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():
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -67,6 +67,16 @@
|
||||
<p v-if="blocked" class="muted" style="margin-top: 10px;">
|
||||
One or more automation steps failed. Fix the error above, then check again.
|
||||
</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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -176,6 +176,16 @@
|
||||
<p v-if="blocked" class="muted" style="margin-top: 10px;">
|
||||
One or more automation steps failed. Fix the error above, then check again.
|
||||
</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
|
||||
@ -259,6 +269,8 @@ const status = ref("");
|
||||
const onboardingUrl = ref("");
|
||||
const tasks = ref([]);
|
||||
const blocked = ref(false);
|
||||
const retrying = ref(false);
|
||||
const retryMessage = ref("");
|
||||
const resending = ref(false);
|
||||
const resendMessage = 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) {
|
||||
verifying.value = true;
|
||||
try {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user