File size: 5,159 Bytes
9fb23ed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c4b3657
 
 
 
 
 
 
 
 
 
 
9fb23ed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c4b3657
 
9fb23ed
 
 
 
 
 
c4b3657
 
 
 
 
 
 
 
 
9fb23ed
 
 
 
 
 
 
c4b3657
9fb23ed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
"""Helpers for talking to the HuggingFace Inference API.

Centralises the three things every Space here needs and previously got wrong:

* **Authentication** -- reads ``HF_TOKEN`` from the environment. The serverless
  Inference Providers backend rejects most anonymous calls (401/402), so a token
  is effectively required. Add an ``HF_TOKEN`` secret in the Space settings.
* **Timeouts** -- a hung provider request no longer blocks the worker forever.
* **Retries** -- transient errors (model cold-start 503s, rate limits) are
  retried with exponential back-off instead of failing on the first hit.

The ``huggingface_hub`` import is deferred into :func:`make_client` so the pure
helpers (:func:`friendly_error`, :func:`with_retry`) can be imported and unit
tested without the dependency installed.
"""

from __future__ import annotations

import logging
import os
import time
from typing import Callable, Optional, TypeVar

logger = logging.getLogger(__name__)

DEFAULT_TIMEOUT = 120  # seconds
MAX_RETRIES = 3
BACKOFF_BASE = 2.0  # seconds

T = TypeVar("T")

_TRANSIENT_MARKERS = (
    "loading",
    "currently loading",
    "starting",
    "503",
    "rate limit",
    "429",
    "too many requests",
    "quota",
    "timeout",
    "timed out",
)

# Billing failures are permanent until the account changes -- retrying just
# delays the error. These win over _TRANSIENT_MARKERS, which would otherwise
# match a credits message that happens to mention "quota".
_PERMANENT_MARKERS = (
    "402",
    "payment required",
    "included credits",
    "subscribe to pro",
    "exceeded your monthly",
)


class InferenceError(RuntimeError):
    """Raised when an inference call fails after exhausting retries."""


def get_token() -> Optional[str]:
    """Return the HF token from the environment, if configured."""
    return os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")


def make_client(model: Optional[str] = None, timeout: int = DEFAULT_TIMEOUT):
    """Create an :class:`InferenceClient` authenticated with the Space token."""
    from huggingface_hub import InferenceClient  # deferred: keeps helpers dep-free

    token = get_token()
    if token is None:
        logger.warning(
            "No HF_TOKEN found in the environment; inference calls will likely be "
            "rejected. Add an HF_TOKEN secret in the Space settings."
        )
    return InferenceClient(model=model, token=token, timeout=timeout)


def _is_transient(exc: Exception) -> bool:
    msg = str(exc).lower()
    if any(marker in msg for marker in _PERMANENT_MARKERS):
        return False
    return any(marker in msg for marker in _TRANSIENT_MARKERS)


def friendly_error(exc: Exception) -> str:
    """Map a raw inference exception to an actionable, user-facing message."""
    msg = str(exc).lower()
    # Checked before rate limiting: a credits message often mentions "quota"
    # too, and before auth: the token is valid, so telling the user to replace
    # it sends them to fix something that is not broken.
    if any(k in msg for k in _PERMANENT_MARKERS):
        return (
            "This account is out of HuggingFace Inference credits, so the request "
            "was declined. The HF_TOKEN is valid -- the monthly free allowance is "
            "used up. Wait for the monthly reset or subscribe to PRO for more."
        )
    if any(k in msg for k in ("rate limit", "429", "too many requests", "quota")):
        return (
            "The model is rate-limited right now. Wait a moment and try again, or "
            "use an HF_TOKEN that has inference credits."
        )
    if any(k in msg for k in ("currently loading", "loading", "503", "starting")):
        return "The model is warming up (cold start). Please try again in ~20 seconds."
    if any(k in msg for k in ("401", "unauthorized", "authentication")):
        return (
            "Inference was rejected for authentication. Set a valid HF_TOKEN secret "
            "in the Space settings."
        )
    if "timeout" in msg or "timed out" in msg:
        return "The request timed out -- the model may be busy. Please try again."
    return f"Inference failed: {exc}"


def with_retry(
    fn: Callable[..., T],
    *args,
    retries: int = MAX_RETRIES,
    sleep: Callable[[float], None] = time.sleep,
    **kwargs,
) -> T:
    """Call *fn* with exponential back-off on transient errors.

    Raises :class:`InferenceError` with a friendly message if all attempts fail.
    """
    last_exc: Optional[Exception] = None
    for attempt in range(retries):
        try:
            return fn(*args, **kwargs)
        except Exception as exc:  # noqa: BLE001 - re-raised via InferenceError
            last_exc = exc
            if not _is_transient(exc) or attempt == retries - 1:
                break
            wait = BACKOFF_BASE * (2**attempt)
            logger.info(
                "Transient inference error (attempt %d/%d); retrying in %.1fs: %s",
                attempt + 1,
                retries,
                wait,
                exc,
            )
            sleep(wait)
    raise InferenceError(friendly_error(last_exc)) from last_exc