{"record":{"id":"fa7f6d854d6ac78f","repo":"harry0703/MoneyPrinterTurbo","slug":"sonilo-returned-an-unexpected-service-response","errorCode":null,"errorMessage":"Sonilo returned an unexpected service response","messagePattern":"Sonilo returned an unexpected service response","errorType":"exception","errorClass":"SoniloError","httpStatus":null,"severity":"error","filePath":"app/services/sonilo.py","lineNumber":109,"sourceCode":"    try:\n        response = requests.get(\n            f\"{_base_url()}{SERVICES_PATH}\",\n            headers={\"Authorization\": f\"Bearer {api_key}\"},\n            timeout=(15, 30),\n        )\n    except requests.RequestException as exc:\n        raise SoniloError(f\"failed to connect to Sonilo: {exc}\") from exc\n    if not response.ok:\n        raise SoniloError(\n            f\"Sonilo connection failed ({response.status_code}): \"\n            f\"{_safe_response_error(response)}\"\n        )\n    try:\n        payload = response.json()\n    except ValueError as exc:\n        raise SoniloError(\"Sonilo returned an invalid service response\") from exc\n    if not isinstance(payload, dict):\n        raise SoniloError(\"Sonilo returned an unexpected service response\")\n    available_services = payload.get(\"available_services\")\n    if not isinstance(available_services, list) or not all(\n        isinstance(service_id, str) for service_id in available_services\n    ):\n        raise SoniloError(\"Sonilo returned an invalid service list\")\n    normalized_services = {\n        _normalize_service_id(service_id) for service_id in available_services\n    }\n    if VIDEO_TO_MUSIC_SERVICE_ID not in normalized_services:\n        raise SoniloError(\"Sonilo video-to-music service is not available for this key\")\n    logger.info(\"Sonilo connection test succeeded\")\n    return payload\n\n\ndef _remove_file(file_path: str) -> None:\n    \"\"\"尽力清理 Sonilo 中间文件，不覆盖调用方正在处理的原始异常。\"\"\"\n    if not file_path or not os.path.exists(file_path):\n        return","sourceCodeStart":91,"sourceCodeEnd":127,"githubUrl":"https://github.com/harry0703/MoneyPrinterTurbo/blob/1f9f19c2021a68d04df228f33e9099a0c947f6f8/app/services/sonilo.py#L91-L127","documentation":"Raised by sonilo.test_connection() when the JSON parses successfully but the top-level value is not a dict — the code expects an object with an 'available_services' key, so a list, string, number, or null payload means the response schema does not match the expected Sonilo services contract.","triggerScenarios":"response.json() returning e.g. [\"video_to_music\"] (bare array), \"ok\" (string), or null from /v1/account/services — API version change returning a different envelope, gateway transforming the payload, or a mock/test server returning a list.","commonSituations":"Sonilo API schema evolution (payload wrapped/unwrapped between versions); custom sonilo_base_url hitting a different API version; API gateways normalizing responses; test doubles returning a bare list of services.","solutions":["Log/inspect the parsed payload in a debug run to see its actual shape (list vs dict vs null)","Drop custom sonilo_base_url to use the default https://api.sonilo.com so the expected schema is served","If the API legitimately changed, update the parsing in app/services/sonilo.py test_connection() to accept the new envelope","In tests, make the mock return {\"available_services\": [\"video_to_music\"]}"],"exampleFix":"# before (test mock)\nrequests.get.return_value.json.return_value = [\"video_to_music\"]  # list -> SoniloError\n\n# after\nrequests.get.return_value.json.return_value = {\"available_services\": [\"video_to_music\"]}","handlingStrategy":"type-guard","validationCode":null,"typeGuard":"def is_sonilo_services_payload(payload: object) -> bool:\n    return (\n        isinstance(payload, dict)\n        and isinstance(payload.get(\"available_services\"), list)\n        and all(isinstance(s, str) for s in payload[\"available_services\"])\n    )","tryCatchPattern":"except SoniloError as e: if 'unexpected service response' in str(e): non-retryable — log the payload shape, revert to default base_url or update the parser to the new API envelope","preventionTips":["Type-check third-party JSON payloads before field access","Pin the API version via base_url when the provider offers versioned hosts","Update mocks to match the real {available_services: [...]} envelope"],"tags":["sonilo","schema","json","api-contract"],"backgroundTag":null,"analyzedSha":"1f9f19c2021a68d04df228f33e9099a0c947f6f8","analyzedAt":"2026-08-14T19:41:05.568Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}