Skip to content

Commit bae9952

Browse files
fix error status code
1 parent cc09439 commit bae9952

9 files changed

Lines changed: 125 additions & 66 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
- removed System tab from the Pioreactor's "Control" dialog. You can see (most) of this data on the Inventory page
44
- added a Self-test tab back to the Pioreactor's "Control" dialog, and to the "Control all Pioreactors"
5+
6+
7+
## Agents
58
- improved unit API retry safety by returning in-progress responses when Huey task locks are held
69
- improved API error messages with structured causes and remediation hints for agents and UI clients
710

core/pioreactor/web/api.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1455,7 +1455,12 @@ def get_all_calibrations_as_yamls(pioreactor_unit: str) -> ResponseReturnValue:
14551455
try:
14561456
results = task.get(blocking=True, timeout=60)
14571457
except (HueyException, TaskException):
1458-
return {"result": False, "filename": None, "msg": "Timed out"}, 500
1458+
abort_with(
1459+
500,
1460+
"Timed out fetching calibrations",
1461+
cause="Timed out waiting for workers to provide calibration archives.",
1462+
remediation="Retry the request and check worker connectivity.",
1463+
)
14591464

14601465
aggregate_buffer = BytesIO()
14611466

@@ -1507,7 +1512,12 @@ def get_entire_dot_pioreactor(pioreactor_unit: str) -> ResponseReturnValue:
15071512
try:
15081513
results = task.get(blocking=True, timeout=120)
15091514
except (HueyException, TaskException):
1510-
return {"result": False, "filename": None, "msg": "Timed out"}, 500
1515+
abort_with(
1516+
500,
1517+
"Timed out fetching .pioreactor archive",
1518+
cause="Timed out waiting for worker responses.",
1519+
remediation="Retry the request and check worker connectivity.",
1520+
)
15111521

15121522
# If only one worker, proxy its ZIP directly
15131523
if isinstance(results, dict) and len(results) == 1:
@@ -2223,12 +2233,21 @@ def export_datasets() -> ResponseReturnValue:
22232233
try:
22242234
status, msg = result(blocking=True, timeout=5 * 60)
22252235
except (HueyException, TaskException):
2226-
status = False
2227-
return {"result": status, "filename": None, "msg": "Task error, or time out"}, 500
2236+
abort_with(
2237+
500,
2238+
"Export task failed or timed out",
2239+
cause="Task error or timeout while exporting datasets.",
2240+
remediation="Retry the export and check server logs if it persists.",
2241+
)
22282242

22292243
if not status:
22302244
publish_to_error_log(msg, "export_datasets")
2231-
return {"result": status, "filename": None, "msg": msg}, 500
2245+
abort_with(
2246+
500,
2247+
"Export task failed",
2248+
cause=msg,
2249+
remediation="Check server logs for details and retry the export.",
2250+
)
22322251

22332252
return {"result": status, "filename": filename, "msg": "Finished"}, 200
22342253

@@ -2323,7 +2342,12 @@ def create_experiment() -> ResponseReturnValue:
23232342
return {"status": "success"}, 201
23242343

23252344
except sqlite3.IntegrityError:
2326-
return {"status": "error"}, 409
2345+
abort_with(
2346+
409,
2347+
"Experiment already exists",
2348+
cause="Experiment name conflicts with an existing experiment.",
2349+
remediation="Choose a different experiment name and retry.",
2350+
)
23272351
except Exception as e:
23282352
publish_to_error_log(str(e), "create_experiment")
23292353
abort_with(500, str(e))
@@ -3287,7 +3311,7 @@ def get_experiment_assignment_for_worker(pioreactor_unit: str) -> ResponseReturn
32873311
if result is None:
32883312
abort_with(
32893313
404,
3290-
f"Worker {pioreactor_unit} does not exist in the cluster.",
3314+
f"Worker {pioreactor_unit} not found.",
32913315
cause=f"Worker '{pioreactor_unit}' not in leader database.",
32923316
remediation="Check the unit name or add the worker to the inventory.",
32933317
)

core/pioreactor/web/app.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from pioreactor.whoami import am_I_leader
2424
from pioreactor.whoami import get_unit_name
2525
from pioreactor.whoami import UNIVERSAL_EXPERIMENT
26+
from werkzeug.exceptions import HTTPException
2627

2728
VERSION = __version__
2829
HOSTNAME = get_unit_name()
@@ -128,6 +129,10 @@ def handle_bad_gateway(e):
128129
502,
129130
)
130131

132+
@app.errorhandler(HTTPException)
133+
def handle_http_exception(e: HTTPException):
134+
return jsonify({"error": e.description}), e.code or 500
135+
131136
@app.after_request
132137
def ensure_error_payload(response: t.Any) -> t.Any:
133138
if response.status_code < 400:

core/pioreactor/web/unit_api.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,12 @@ def get_clock_time():
386386
current_time = current_utc_timestamp()
387387
return jsonify({"status": "success", "clock_time": current_time}), 200
388388
except Exception as e:
389-
return jsonify({"status": "error", "message": str(e)}), 500
389+
abort_with(
390+
500,
391+
"Failed to read clock time",
392+
cause=str(e),
393+
remediation="Check system clock availability and server logs, then retry.",
394+
)
390395

391396

392397
# PATCH / POST to set clock time

core/pioreactor/web/utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ def abort_with(
4040
if merged_error_info:
4141
payload["error_info"] = merged_error_info
4242

43-
abort(jsonify(payload), status)
43+
response = jsonify(payload)
44+
response.status_code = status
45+
abort(response)
4446
raise AssertionError("abort should not return")
4547

4648

frontend/src/Pioreactor.jsx

Lines changed: 41 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -733,7 +733,6 @@ function SettingsActionsDialog(props) {
733733
const {client, subscribeToTopic, unsubscribeFromTopic} = useMQTT();
734734
const selfTestExperiment = "$experiment";
735735
const selfTestSettings = props.jobs?.self_test?.publishedSettings || null;
736-
const selfTestDefinitionAvailable = Boolean(selfTestSettings);
737736
const selfTestSettingTypes = useMemo(() => {
738737
if (!selfTestSettings) {
739738
return {};
@@ -1533,7 +1532,7 @@ function SettingsActionsDialog(props) {
15331532
loading={isSelfTestRunning || selfTestStartPending}
15341533
loadingPosition="start"
15351534
endIcon={<PlayArrowIcon />}
1536-
disabled={isSelfTestRunning || selfTestStartPending || !selfTestDefinitionAvailable}
1535+
disabled={isSelfTestRunning || selfTestStartPending }
15371536
onClick={handleRunSelfTest}
15381537
sx={{textTransform: "none"}}
15391538
>
@@ -1543,54 +1542,47 @@ function SettingsActionsDialog(props) {
15431542

15441543
<ControlDivider/>
15451544

1546-
{!selfTestDefinitionAvailable && (
1547-
<Alert severity="warning">
1548-
Self-test is unavailable on this cluster.
1549-
</Alert>
1550-
)}
15511545

1552-
{selfTestDefinitionAvailable && (
1553-
<Accordion disableGutters sx={{boxShadow: "none", "&:before": {display: "none"}}}>
1554-
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
1555-
<Box sx={{display: "flex", alignItems: "center", gap: 1}}>
1556-
{renderSelfTestSummaryIcon()}
1557-
<Typography>{props.label ? `${props.label} / ${props.unit}` : props.unit}</Typography>
1558-
</Box>
1559-
</AccordionSummary>
1560-
<AccordionDetails>
1561-
{availableSelfTestGroups.length === 0 ? (
1562-
<Typography variant="body2">No self-test checks available.</Typography>
1563-
) : (
1564-
<>
1565-
{availableSelfTestGroups.map((group) => (
1566-
<List
1567-
key={`self-test-${props.unit}-${group.title}`}
1568-
dense
1569-
disablePadding
1570-
subheader={
1571-
<ListSubheader style={{lineHeight: "20px"}} component="div" disableSticky={true} disableGutters={true}>
1572-
{group.title}
1573-
</ListSubheader>
1574-
}
1575-
>
1576-
{group.tests.map((test) => (
1577-
<ListItem key={`self-test-${props.unit}-${test.key}`} sx={{pt: 0, pb: 0}}>
1578-
<ListItemIcon sx={{minWidth: "30px"}}>
1579-
{renderSelfTestIcon(test.key)}
1580-
</ListItemIcon>
1581-
<ListItemText
1582-
primary={test.label}
1583-
secondary={renderSelfTestSecondary(test)}
1584-
/>
1585-
</ListItem>
1586-
))}
1587-
</List>
1588-
))}
1589-
</>
1590-
)}
1591-
</AccordionDetails>
1592-
</Accordion>
1593-
)}
1546+
<Accordion disableGutters sx={{boxShadow: "none", "&:before": {display: "none"}}}>
1547+
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
1548+
<Box sx={{display: "flex", alignItems: "center", gap: 1}}>
1549+
{renderSelfTestSummaryIcon()}
1550+
<Typography>{props.label ? `${props.label} / ${props.unit}` : props.unit}</Typography>
1551+
</Box>
1552+
</AccordionSummary>
1553+
<AccordionDetails>
1554+
{availableSelfTestGroups.length === 0 ? (
1555+
<Typography variant="body2">No self-test checks available.</Typography>
1556+
) : (
1557+
<>
1558+
{availableSelfTestGroups.map((group) => (
1559+
<List
1560+
key={`self-test-${props.unit}-${group.title}`}
1561+
dense
1562+
disablePadding
1563+
subheader={
1564+
<ListSubheader style={{lineHeight: "20px"}} component="div" disableSticky={true} disableGutters={true}>
1565+
{group.title}
1566+
</ListSubheader>
1567+
}
1568+
>
1569+
{group.tests.map((test) => (
1570+
<ListItem key={`self-test-${props.unit}-${test.key}`} sx={{pt: 0, pb: 0}}>
1571+
<ListItemIcon sx={{minWidth: "30px"}}>
1572+
{renderSelfTestIcon(test.key)}
1573+
</ListItemIcon>
1574+
<ListItemText
1575+
primary={test.label}
1576+
secondary={renderSelfTestSecondary(test)}
1577+
/>
1578+
</ListItem>
1579+
))}
1580+
</List>
1581+
))}
1582+
</>
1583+
)}
1584+
</AccordionDetails>
1585+
</Accordion>
15941586
</TabPanel>
15951587

15961588
</DialogContent>

frontend/src/SingleCalibrationPage.jsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useConfirm } from 'material-ui-confirm';
44
import { CircularProgress, Button, Typography, Box, Divider } from "@mui/material";
55
import Dialog from '@mui/material/Dialog';
66
import DialogTitle from '@mui/material/DialogTitle';
7+
import Alert from '@mui/material/Alert';
78
import DialogContent from '@mui/material/DialogContent';
89
import DialogActions from '@mui/material/DialogActions';
910
import IconButton from '@mui/material/IconButton';
@@ -258,7 +259,12 @@ function SingleCalibrationPage(props) {
258259
const apiUrl = `/api/workers/${pioreactorUnit}/calibrations/${device}/${calibrationName}`;
259260
try {
260261
const data = await fetchTaskResult(apiUrl)
261-
setCalibration(data.result[pioreactorUnit]);
262+
if (data.result[pioreactorUnit].error){
263+
setCalibration(null);
264+
} else{
265+
setCalibration(data.result[pioreactorUnit]);
266+
}
267+
262268
} catch (err) {
263269
console.error("Failed to fetch calibration:", err);
264270
} finally {
@@ -377,9 +383,9 @@ function SingleCalibrationPageCard({ pioreactorUnit, device, calibrationName, ca
377383
if (!calibration) {
378384
return (
379385
<Box sx={{textAlign: "center", mb: '50px', mt: "50px"}}>
380-
<Typography variant="body2" component="p" color="textSecondary">
386+
<Alert severity="error" sx={{ display: "inline-flex", textAlign: "left" }}>
381387
Unable to find calibration data.
382-
</Typography>
388+
</Alert>
383389
</Box>
384390
);
385391
}

frontend/src/SingleEstimatorPage.jsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { CircularProgress, Button, Typography, Box, Divider } from "@mui/materia
55
import Dialog from '@mui/material/Dialog';
66
import DialogTitle from '@mui/material/DialogTitle';
77
import DialogContent from '@mui/material/DialogContent';
8+
import Alert from '@mui/material/Alert';
89
import DialogActions from '@mui/material/DialogActions';
910
import IconButton from '@mui/material/IconButton';
1011
import { fetchTaskResult } from "./utilities";
@@ -208,8 +209,12 @@ function SingleEstimatorPage(props) {
208209
setLoading(true);
209210
const apiUrl = `/api/workers/${pioreactorUnit}/estimators/${device}/${estimatorName}`;
210211
try {
211-
const data = await fetchTaskResult(apiUrl);
212-
setEstimator(data.result[pioreactorUnit]);
212+
const data = await fetchTaskResult(apiUrl)
213+
if (data.result[pioreactorUnit].error){
214+
setEstimator(null);
215+
} else{
216+
setEstimator(data.result[pioreactorUnit]);
217+
}
213218
} catch (err) {
214219
console.error("Failed to fetch estimator:", err);
215220
} finally {
@@ -326,9 +331,9 @@ function SingleEstimatorPageCard({ pioreactorUnit, device, estimatorName, estima
326331
if (!estimator) {
327332
return (
328333
<Box sx={{ textAlign: "center", mb: '50px', mt: "50px" }}>
329-
<Typography variant="body2" component="p" color="textSecondary">
334+
<Alert severity="error" sx={{ display: "inline-flex", textAlign: "left" }}>
330335
Unable to find estimator data.
331-
</Typography>
336+
</Alert>
332337
</Box>
333338
);
334339
}

frontend/src/utilities.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,27 @@ export async function checkTaskCallback(callbackURL, {maxRetries = 100, delayMs
131131
export async function fetchTaskResult(endpoint, {fetchOptions = {}, maxRetries = 100, delayMs = 200} = {}) {
132132
const response = await fetch(endpoint, fetchOptions);
133133
if (!response.ok) {
134-
throw new Error(`HTTP error! Status: ${response.status}`);
134+
let message = `HTTP error! Status: ${response.status}`;
135+
try {
136+
const payload = await response.json();
137+
if (payload?.error_info?.message) {
138+
message = payload.error_info.message;
139+
} else if (payload?.error) {
140+
message = payload.error;
141+
}
142+
} catch (error) {
143+
// ignore JSON parse errors and fall back to default message
144+
}
145+
throw new Error(message);
135146
}
136147
const payload = await response.json();
137148
if (!payload.result_url_path) {
149+
if (payload?.error_info?.message) {
150+
throw new Error(payload.error_info.message);
151+
}
152+
if (payload?.error) {
153+
throw new Error(payload.error);
154+
}
138155
throw new Error('No result_url_path in response');
139156
}
140157
return checkTaskCallback(payload.result_url_path, {maxRetries, delayMs});

0 commit comments

Comments
 (0)