harry0703/MoneyPrinterTurbo · error · SoniloError

Sonilo returned an unexpected service response

Error message

Sonilo returned an unexpected service response

What it means

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.

Source

Thrown at app/services/sonilo.py:109

    try:
        response = requests.get(
            f"{_base_url()}{SERVICES_PATH}",
            headers={"Authorization": f"Bearer {api_key}"},
            timeout=(15, 30),
        )
    except requests.RequestException as exc:
        raise SoniloError(f"failed to connect to Sonilo: {exc}") from exc
    if not response.ok:
        raise SoniloError(
            f"Sonilo connection failed ({response.status_code}): "
            f"{_safe_response_error(response)}"
        )
    try:
        payload = response.json()
    except ValueError as exc:
        raise SoniloError("Sonilo returned an invalid service response") from exc
    if not isinstance(payload, dict):
        raise SoniloError("Sonilo returned an unexpected service response")
    available_services = payload.get("available_services")
    if not isinstance(available_services, list) or not all(
        isinstance(service_id, str) for service_id in available_services
    ):
        raise SoniloError("Sonilo returned an invalid service list")
    normalized_services = {
        _normalize_service_id(service_id) for service_id in available_services
    }
    if VIDEO_TO_MUSIC_SERVICE_ID not in normalized_services:
        raise SoniloError("Sonilo video-to-music service is not available for this key")
    logger.info("Sonilo connection test succeeded")
    return payload


def _remove_file(file_path: str) -> None:
    """尽力清理 Sonilo 中间文件,不覆盖调用方正在处理的原始异常。"""
    if not file_path or not os.path.exists(file_path):
        return

View on GitHub (pinned to 1f9f19c202)

Solutions

  1. Log/inspect the parsed payload in a debug run to see its actual shape (list vs dict vs null)
  2. Drop custom sonilo_base_url to use the default https://api.sonilo.com so the expected schema is served
  3. If the API legitimately changed, update the parsing in app/services/sonilo.py test_connection() to accept the new envelope
  4. In tests, make the mock return {"available_services": ["video_to_music"]}

Example fix

# before (test mock)
requests.get.return_value.json.return_value = ["video_to_music"]  # list -> SoniloError

# after
requests.get.return_value.json.return_value = {"available_services": ["video_to_music"]}
Defensive patterns

Strategy: type-guard

Type guard

def is_sonilo_services_payload(payload: object) -> bool:
    return (
        isinstance(payload, dict)
        and isinstance(payload.get("available_services"), list)
        and all(isinstance(s, str) for s in payload["available_services"])
    )

Try / catch

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

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of harry0703/MoneyPrinterTurbo@1f9f19c202 (2026-08-14). Data as JSON: /api/errors/fa7f6d854d6ac78f. Report an issue: GitHub.