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:
|
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():
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user