odysseus-dev/odysseus · error · HTTPException

Expected a JSON object

Error message

Expected a JSON object

What it means

HTTP 400 raised by POST /api/import when the parsed body is not a JSON object (dict). Arrays, bare strings, numbers, or null parse successfully but fail isinstance(body, dict). The import merges top-level keys like 'memories', so a non-object body is structurally unusable.

Source

Thrown at routes/backup_routes.py:74

        filename = f"odysseus_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
        return Response(
            content=json.dumps(export_data, indent=2, ensure_ascii=False),
            media_type="application/json",
            headers={"Content-Disposition": f"attachment; filename={filename}"},
        )

    @router.post("/api/import")
    async def import_data(request: Request):
        """Import user data from a previously exported JSON file. Merges with existing data."""
        require_admin(request)
        user = get_current_user(request)
        try:
            body = await request.json()
        except Exception:
            raise HTTPException(400, "Invalid JSON")

        if not isinstance(body, dict):
            raise HTTPException(400, "Expected a JSON object")

        imported = []

        # ── Memories ──
        if "memories" in body and isinstance(body["memories"], list):
            # Strict load: importing on top of an unreadable store would write
            # only the incoming rows back and drop everything already saved.
            try:
                existing = memory_manager.load_all_for_update()
            except MemoryStoreUnreadable as e:
                logger.error("Refusing to import memories: %s", e)
                raise HTTPException(
                    503, "Memory store is temporarily unreadable — nothing was imported."
                )
            # Dedup against THIS user's own memories only. Using every tenant's
            # rows (load_all) meant a memory whose text matched any other
            # user's was silently skipped, so the importing user lost their own
            # data. The full store is still saved back below.

View on GitHub (pinned to f9235ebbf1)

Solutions

  1. Upload the export file exactly as produced by /api/export — a top-level object.
  2. If importing custom data, wrap it: {"memories": [...]} etc.
  3. Inspect the first character of the file: '[' means you have a list, '{' means an object.

Example fix

# before
# file contains just the array:  [ {"text": "..."} ]

# after
# file contains the full object:  {"memories": [ {"text": "..."} ]}
Defensive patterns

Strategy: type-guard

Validate before calling

const data = JSON.parse(text);
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
  throw new Error('Export must be a JSON object, not a list');
}

Type guard

const isJsonObject = (v: unknown): v is Record<string, unknown> =>
  typeof v === 'object' && v !== null && !Array.isArray(v);

Try / catch

if (res.status === 400 && detail === 'Expected a JSON object') { showFileError('Use the full export file (an object)'); }

Prevention

When it happens

Trigger: POST /api/import with a JSON array (e.g. the user wrapped the export in [...] or uploaded an inner list), a quoted string body '"..."', or literal null.

Common situations: User exports a list from another tool and tries to import it; copying an inner array from an export file instead of the whole object; jq misuse (jq '.memories' instead of '.').

Related errors


AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14). Data as JSON: /api/errors/d3252ac3f5216e37. Report an issue: GitHub.