Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/utopia-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ pub enum AppError {
Forbidden,
#[error("{0}")]
Conflict(String),
/// Localizable conflict; legacy internal callers may still use Conflict.
#[error("{message}")]
CodedConflict { code: &'static str, message: String },
#[error("{0}")]
Validation(String),
/// 带稳定 code 的校验错误。**message 仍是英文原句**——它是给不做本地化的
Expand Down
57 changes: 56 additions & 1 deletion crates/utopia-server/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pub type ApiResult<T> = Result<T, ApiErr>;

impl IntoResponse for ApiErr {
fn into_response(self) -> Response {
// code 与 detail 只有 Invalid 才有;其余保持原样,转换可以一条条推进
// Legacy errors keep their response; localizable conflicts share the code envelope.
let mut code: Option<&'static str> = None;
let mut detail: Option<String> = None;
let (status, message) = match &self.0 {
Expand All @@ -33,6 +33,10 @@ impl IntoResponse for ApiErr {
AppError::NotFound => (StatusCode::NOT_FOUND, self.0.to_string()),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.0.to_string()),
AppError::Forbidden => (StatusCode::FORBIDDEN, self.0.to_string()),
AppError::CodedConflict { code: c, message } => {
code = Some(c);
(StatusCode::CONFLICT, message.clone())
}
AppError::Conflict(m) => (StatusCode::CONFLICT, m.clone()),
AppError::Validation(m) => (StatusCode::UNPROCESSABLE_ENTITY, m.clone()),
AppError::Db(e) => {
Expand Down Expand Up @@ -60,3 +64,54 @@ impl IntoResponse for ApiErr {
(status, Json(body)).into_response()
}
}

#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn conflict_codes_preserve_other_error_mappings() {
for (error, status, code) in [
(
AppError::CodedConflict {
code: "alignment_busy",
message: "reworded".into(),
},
409,
Some("alignment_busy"),
),
(
AppError::CodedConflict {
code: "another_conflict",
message: "busy".into(),
},
409,
Some("another_conflict"),
),
(AppError::Conflict("legacy".into()), 409, None),
(AppError::Unauthorized, 401, None),
(AppError::Forbidden, 403, None),
(AppError::NotFound, 404, None),
(
AppError::invalid("bad_input", "invalid"),
422,
Some("bad_input"),
),
(
AppError::Other(anyhow::anyhow!("private detail")),
500,
None,
),
] {
let response = ApiErr(error).into_response();
assert_eq!(response.status().as_u16(), status);
let bytes = axum::body::to_bytes(response.into_body(), 4096)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(body["code"].as_str(), code);
if status == 500 {
assert_eq!(body["error"], "Internal server error");
}
}
}
}
2 changes: 1 addition & 1 deletion crates/utopia-server/src/type_alignment_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ mod review_locks {
let (status, body) = tokio::time::timeout(Duration::from_secs(6), tasks.join_next())
.await?.expect("request task")??;
anyhow::ensure!(status == StatusCode::CONFLICT, "expected 409, got {status}: {body}");
anyhow::ensure!(body["error"].as_str().unwrap().contains("try again"));
anyhow::ensure!(body["code"] == "alignment_busy");
anyhow::ensure!(snapshot(&f).await? == before, "timeout left a partial write");
anyhow::ensure!(events.try_recv().is_err(), "failed request emitted success");
anyhow::ensure!(session(&pool).await? == original_session, "session setting leaked");
Expand Down
7 changes: 4 additions & 3 deletions crates/utopia-store/src/type_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,11 @@ pub async fn decide_and_apply_human(
if matches!(&error, AppError::Db(sqlx::Error::Database(e))
if e.code().as_deref() == Some("55P03"))
{
return Err(AppError::Conflict(
"This kind word is being updated by another operation. Please try again shortly."
return Err(AppError::CodedConflict {
code: "alignment_busy",
message: "This kind word is being updated by another operation. Please try again shortly."
.into(),
));
});
}
Err(error)
}
Expand Down
1 change: 1 addition & 0 deletions web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1922,6 +1922,7 @@ export const en = {
alignmentStatements: (n: number) => (n === 1 ? "1 statement" : `${n} statements`),
alignmentEntities: (n: number) => (n === 1 ? "1 thing" : `${n} things`),
alignmentVotes: (first: string, second: string) => `Votes: ${first} · ${second}`,
alignmentConflict: "This decision conflicts with the current state. Refresh and review it before trying again.",
alignmentKindWordBusy: "This kind word is being updated by another operation. Please try again shortly.",
alignmentTyped: (kept: number, retired: number) =>
`Typed graph recomputed: ${kept} statements typed, ${retired} rows retired`,
Expand Down
1 change: 1 addition & 0 deletions web/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1681,6 +1681,7 @@ export const zh: Strings = {
alignmentStatements: (n: number) => `${n} 条陈述`,
alignmentEntities: (n: number) => `${n} 样东西`,
alignmentVotes: (first: string, second: string) => `两票:${first} · ${second}`,
alignmentConflict: "此决定与当前状态冲突。请刷新并核对后再试。",
alignmentKindWordBusy: "这个类别词正在被其他操作更新,请稍后重试。",
alignmentTyped: (kept: number, retired: number) => `类型化图谱已重算:${kept} 条成了类型化事实,${retired} 行作废`,
defects: "本体自相矛盾",
Expand Down
6 changes: 2 additions & 4 deletions web/src/pages/Review.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { alignmentErrorMessage } from "./reviewErrors";
import { useEffect, useState } from "react";
import { LayoutDashboard } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearch } from "@tanstack/react-router";
import {
api,
ApiError,
type AgentDecision,
type AgentPrecedent,
type AxiomViolation,
Expand Down Expand Up @@ -1365,9 +1365,7 @@ export function Review() {
mutationFn: ({ kindWord, cls }: { kindWord: string; cls: string | null }) =>
api.decideAlignmentKindWord(kb!.id, kindWord, cls),
onError: (e) => toast.error(
e instanceof ApiError && e.status === 409
? S.review.alignmentKindWordBusy
: (e as Error).message,
alignmentErrorMessage(e),
),
onSettled: invalidate,
});
Expand Down
21 changes: 21 additions & 0 deletions web/src/pages/reviewErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { afterEach, describe, expect, it, vi } from "vitest";

afterEach(() => { vi.unstubAllGlobals(); vi.resetModules(); });
for (const lang of ["en", "zh"]) {
describe(lang, () => {
it("uses the stable busy code and gives other conflicts a distinct fallback", async () => {
vi.stubGlobal("localStorage", { getItem: () => lang });
const { ApiError } = await import("../api");
const { S } = await import("../i18n");
const { alignmentErrorMessage: message } = await import("./reviewErrors");
expect(message(new ApiError(409, "changed server wording", "alignment_busy"))).toBe(S.review.alignmentKindWordBusy);
for (const code of [undefined, "already_decided", "future_code"]) {
expect(message(new ApiError(409, "busy, try again", code))).toBe(S.review.alignmentConflict);
expect(message(new ApiError(409, "busy", code))).not.toBe(S.review.alignmentKindWordBusy);
}
for (const status of [401, 403, 404, 422, 500]) {
expect(message(new ApiError(status, "original error", "alignment_busy"))).toBe("original error");
}
});
});
}
12 changes: 12 additions & 0 deletions web/src/pages/reviewErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ApiError } from "../api";
import { S } from "../i18n";

/** A 409 says conflict, not which operation conflicted. Never infer busy from prose. */
export function alignmentErrorMessage(error: unknown): string {
if (error instanceof ApiError && error.status === 409) {
return error.code === "alignment_busy"
? S.review.alignmentKindWordBusy
: S.review.alignmentConflict;
}
return (error as Error).message;
}
Loading