odysseus-dev/odysseus · error · HTTPException
Image update failed
Error message
Image update failed
What it means
HTTP 500 raised by POST /api/gallery/{image_id}/replace when the SQLAlchemy db.commit() fails after the new file bytes were already written to disk. The handler rolls back, logs 'gallery_replace: DB commit failed' with a traceback, and surfaces a generic 500. Root causes are DB-level: constraint violations, lock timeouts, dropped connections, or a dirty session.
Source
Thrown at routes/gallery/gallery_routes.py:471
img_path = _gallery_image_path(img.filename)
img_path.write_bytes(content)
# Refresh dimensions in case the editor resized the canvas.
# updated_at auto-bumps via TimestampMixin's onupdate hook.
try:
from PIL import Image
from io import BytesIO
with Image.open(BytesIO(content)) as new_im:
img.width = new_im.width
img.height = new_im.height
except Exception:
pass
try:
db.commit()
except Exception:
db.rollback()
logger.exception("gallery_replace: DB commit failed")
raise HTTPException(500, "Image update failed")
return {"ok": True, "width": img.width, "height": img.height}
finally:
db.close()
# ---- POST /api/gallery/{image_id}/rename ----
@router.post("/api/gallery/{image_id}/rename")
async def gallery_rename(request: Request, image_id: str):
"""Rename a gallery photo. Stores the new name in the `prompt`
column (which serves as the user-facing label for uploaded
photos that have no AI prompt)."""
user = get_current_user(request)
data = await request.json()
new_name = (data.get("name") or "").strip()
if not new_name:
raise HTTPException(400, "Name cannot be empty")
if len(new_name) > 500:
raise HTTPException(400, "Name too long")
db = SessionLocal()View on GitHub (pinned to f9235ebbf1)
Solutions
- Read the server log for the 'gallery_replace: DB commit failed' traceback to get the real DB error.
- If SQLite, enable WAL or serialize writes to reduce 'database is locked'.
- If a constraint violation, inspect the GalleryImage row (width/height/prompt) for invalid values.
- Retry the request once after the transient condition clears; then re-check that the on-disk file matches the DB dimensions.
Example fix
// before: fire-and-forget
await replaceImage(id, fd);
// after: retry once on 500, then resync
try { await replaceImage(id, fd); }
catch (e) { if (e.status === 500) await replaceImage(id, fd); else throw e; } Defensive patterns
Strategy: retry
Try / catch
try { await replaceImage(id, fd); }
catch (e) {
if (e.status !== 500) throw e;
await sleep(500);
try { await replaceImage(id, fd); } // one retry for transient DB lock
catch { await reloadImage(id); } // resync metadata with the already-written file
} Prevention
- Keep DB writes short to avoid lock timeouts
- Watch server logs for the 'DB commit failed' traceback
- After a 500 replace, GET the image to verify disk/DB consistency
When it happens
Trigger: Concurrent replaces on the same row hitting a lock timeout; DB restart or pool connection loss between the query and the commit; width/height assignment from a failed PIL parse leaving invalid state that violates a constraint.
Common situations: SQLite under concurrent writes (database is locked); MySQL/Postgres lock wait timeout; the new file on disk is now newer than the DB row (file updated but metadata stale) after this error.
Related errors
- Failed to fetch gallery library
- HTTP ${resp.status}${detail ? `: ${detail}` : ''}
- HTTP ${saveRes.status}: ${errBody.substring(0, 120)}
- Assistant session could not be resolved
- Assistant not available
AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14).
Data as JSON: /api/errors/3d28008046be739d.
Report an issue: GitHub.