Big audit + fix release driven by two full-codebase Gemini Pro reviews and first-party verification against the live ElevenLabs API spec. Security - Scrub API keys from every raised error (URL params, JSON detail, conn errors). Defense against leaking into ComfyUI logs / workflow PNGs. - AccountInfo redacts PII (email, name, billing IDs) by default and now uses /v1/user/subscription (no PII). - Download size cap (500MB default) — was unbounded BytesIO. - Multipart file pointers reset between retries (was sending empty body on attempt 2). - SaveAudio sanitizes filename + subdir against ../ traversal. Fixed — Credit-burning bugs - IS_CHANGED=NaN removed from paid nodes; replaced with InputCacheMixin that hashes (inputs minus api_key) so identical re-queues don't re-bill. - VoiceClone / VoiceCreate require explicit create=True (was uploading on every queue, burning voice slots). Fixed — API contract bugs (silent failures vs current spec) - Music: uses music_length_ms (was duration_seconds), 600s max (was 300), exposes composition_plan, respect_sections_durations, store_for_inpainting, sign_with_c2pa, model_id=music_v1. - SFX: model_id=eleven_text_to_sound_v2 (was eleven_sfx_v2 — invalid). - TTS: forces apply_text_normalization=off for eleven_v3 (only valid value per spec); raises early if turbo/flash_v2_5 lacks language_code. - STT: actually sends timestamps_granularity=none when chosen. - TTS: malformed pronunciation_dictionary_locators raises instead of silently dropping. Fixed — Audio I/O - PCM decode pads odd byte count so truncated streams don't ValueError. - audio_to_comfy returns 1s silence on empty body (was crashing). - Fallback silence is 1s @ 44.1kHz, not [1,1,1] (downstream STFT was asserting window_size). - comfy_audio_to_bytes accepts 1-D / 2-D / 3-D waveforms. - NamedTemporaryFile replaced with BytesIO (Windows handle leak + reopen). - parse_error_response sniffs Content-Type, returns <binary response> for non-text bodies (was UnicodeDecodeError on binary 500s). Fixed — Retry hardening - Retry-After honors HTTP-date format (RFC 7231). - Exponential backoff clamped to 60s + ±25% jitter. - Voice cache bounded LRU (32 entries) — was unbounded growth. - Pagination hard-cap 100 pages (belt-and-braces). Added — 11 new utility nodes GetVoiceByName, VoiceTagInserter, SubtitleExport (SRT/VTT), AudioConcat (resample + broadcast), AudioNormalize (peak/RMS dBFS), AudioMetadata, AudioChannels (mono/stereo/strip), AudioTrim, CostEstimator, VoiceSettingsPreset, SaveAudio (sanitized). Added — Quality-of-life - enable_logging toggle (zero-retention mode) on TTS/TTSTimestamps/ STS/Dialogue. - FetchVoices: gender filter + count output. - VoiceDesign: returns all 3 previews (was throwing away the 2 you paid for); preview_index selector. - Dialogue: validates total text length, explicit model dropdown, defaults normalization=off (required for v3). - STT: adds scribe_v1. - SFX: live duration validation (0.5-30s). - previous_text/next_text tooltips clarified (context, not output). Tests: 101/101 passing (was 0). All HTTP mocked — no real API calls. - test_audio_conversions.py (13) — AUDIO dict shape, PCM round-trip - test_errors_scrubbing.py (15) — secret-leakage regression, parser - test_retry.py (18) — backoff, file-pointer reset, size cap - test_pagination.py (7) — termination + LRU bound - test_nodes.py (48) — node validation, credit safety, utility nodes Node count: 15 -> 26. Audit notes in CHANGELOG.md.
133 lines
4.5 KiB
Python
133 lines
4.5 KiB
Python
"""Standardized API error classes and response parsing for all services."""
|
|
|
|
import json
|
|
import re
|
|
|
|
|
|
class APIError(RuntimeError):
|
|
"""Base API error with service name, HTTP status code, and detail message."""
|
|
|
|
def __init__(self, service: str, status_code: int, detail: str):
|
|
self.service = service
|
|
self.status_code = status_code
|
|
self.detail = detail
|
|
super().__init__(f"[{service}] HTTP {status_code}: {detail}")
|
|
|
|
|
|
class APITransientError(APIError):
|
|
"""Retryable API error (e.g. 429, 500, 502, 503, 504)."""
|
|
pass
|
|
|
|
|
|
class APIPermanentError(APIError):
|
|
"""Non-retryable API error (e.g. 400, 401, 403, 404)."""
|
|
pass
|
|
|
|
|
|
class APIQuotaError(APIPermanentError):
|
|
"""Credits or quota exhausted."""
|
|
pass
|
|
|
|
|
|
_MAX_DETAIL_LEN = 500
|
|
# Pattern for ElevenLabs API keys: typically `xi_` or hex of length ~40-48,
|
|
# but we also redact anything that looks like a high-entropy alnum token >=20 chars
|
|
# in case a key flows through. Conservative: only redact tokens that look key-ish.
|
|
_KEY_PAT = re.compile(r"(?i)((?:xi[_-])?(?:api[_-])?key[=:\s\"\']\s*)([A-Za-z0-9_\-\.]{20,})")
|
|
_URL_PARAM_PAT = re.compile(r"(?i)(\?|&)(xi[-_]api[-_]key|api[-_]key|key|token)=([^&\s\"\']+)")
|
|
|
|
|
|
def _safe_body(resp_or_body) -> str:
|
|
"""Extract a safe textual body from a response or raw body.
|
|
|
|
Returns a short placeholder for binary / huge / undecodable bodies so we
|
|
never echo an entire 5MB binary stream into an exception message.
|
|
"""
|
|
# Accept either a string or a requests.Response
|
|
if isinstance(resp_or_body, str):
|
|
body = resp_or_body
|
|
else:
|
|
try:
|
|
ct = resp_or_body.headers.get("Content-Type", "") if hasattr(resp_or_body, "headers") else ""
|
|
ct_l = ct.lower()
|
|
if any(t in ct_l for t in ("audio/", "video/", "image/", "octet-stream")):
|
|
return "<binary response>"
|
|
body = resp_or_body.text
|
|
except Exception:
|
|
return "<unreadable response>"
|
|
if body is None:
|
|
return ""
|
|
if len(body) > 4096:
|
|
body = body[:4096] + "...<truncated>"
|
|
return body
|
|
|
|
|
|
def scrub_secrets(text: str, extra_secrets=None) -> str:
|
|
"""Best-effort scrub of API keys / tokens from a string.
|
|
|
|
1. Replaces any explicit `extra_secrets` strings (e.g. the user's key
|
|
passed by caller) wholesale.
|
|
2. Redacts `?xi-api-key=...`, `&api_key=...` query-string params.
|
|
3. Redacts key-like patterns near `key:` / `api_key=` markers.
|
|
|
|
NOT cryptographically rigorous — defense in depth, not a perfect filter.
|
|
"""
|
|
if not text:
|
|
return text
|
|
out = text
|
|
if extra_secrets:
|
|
for s in extra_secrets:
|
|
if s and isinstance(s, str) and len(s) >= 8:
|
|
out = out.replace(s, "***REDACTED***")
|
|
out = _URL_PARAM_PAT.sub(lambda m: f"{m.group(1)}{m.group(2)}=***REDACTED***", out)
|
|
out = _KEY_PAT.sub(lambda m: f"{m.group(1)}***REDACTED***", out)
|
|
return out
|
|
|
|
|
|
def parse_error_response(service: str, status_code: int, body, extra_secrets=None) -> APIError:
|
|
"""Parse an error response body and return the appropriate APIError subclass.
|
|
|
|
`body` may be a string OR a `requests.Response` — Content-Type sniffing
|
|
avoids decoding binary payloads as text. `extra_secrets` (e.g. the API
|
|
key) is scrubbed from the detail message before construction so user
|
|
secrets cannot leak into ComfyUI logs / UI / saved workflow PNGs.
|
|
"""
|
|
body_str = _safe_body(body)
|
|
detail = ""
|
|
|
|
try:
|
|
data = json.loads(body_str)
|
|
|
|
if isinstance(data, dict) and "detail" in data:
|
|
d = data["detail"]
|
|
if isinstance(d, dict) and "message" in d:
|
|
detail = d["message"]
|
|
elif isinstance(d, str):
|
|
detail = d
|
|
else:
|
|
detail = str(d)
|
|
elif isinstance(data, dict) and "message" in data:
|
|
detail = data["message"]
|
|
else:
|
|
detail = str(data)
|
|
|
|
except (json.JSONDecodeError, ValueError, TypeError):
|
|
detail = body_str
|
|
|
|
if not isinstance(detail, str):
|
|
detail = str(detail)
|
|
|
|
detail = scrub_secrets(detail, extra_secrets=extra_secrets)
|
|
|
|
if len(detail) > _MAX_DETAIL_LEN:
|
|
detail = detail[:_MAX_DETAIL_LEN] + "..."
|
|
|
|
if status_code == 429 or status_code >= 500:
|
|
return APITransientError(service, status_code, detail)
|
|
elif status_code in (402, 403) and any(
|
|
kw in detail.lower() for kw in ("quota", "credit", "limit", "exhausted")
|
|
):
|
|
return APIQuotaError(service, status_code, detail)
|
|
else:
|
|
return APIPermanentError(service, status_code, detail)
|