File size: 9,371 Bytes
bac4901
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
"""
space_client.py — HTTP client for distributed mode.

Calls the remote Face Space and Object Space for AI inference.
Handles:
  - Persistent aiohttp session (created once at startup)
  - Exponential-backoff retry for cold-start wakeups (HF free tier)
  - Keep-alive background pinger to prevent spaces sleeping
  - Structured exceptions that search.py / upload.py already handle
"""

import asyncio
import io
import logging
import os

import aiohttp

# ── Space URLs (set these as secrets in the Gateway HF Space) ────────────────
FACE_SPACE_URL   = os.getenv("FACE_SPACE_URL", "").rstrip("/")
OBJECT_SPACE_URL = os.getenv("OBJECT_SPACE_URL", "").rstrip("/")

# ── Timeouts ─────────────────────────────────────────────────────────────────
# HF free-tier spaces can take 60-90 s to cold-start.
# Keep TOTAL high so we wait through the wake-up; CONNECT stays short.
_CONNECT_TIMEOUT = 10      # seconds — TCP handshake
_TOTAL_TIMEOUT   = 120     # seconds — full request including model inference

# ── Retry config (exponential backoff for cold starts) ────────────────────────
_MAX_RETRIES   = 3
_RETRY_BACKOFF = [5, 15, 30]   # seconds to wait before each retry

logger = logging.getLogger("space_client")

# ── Singleton aiohttp session ─────────────────────────────────────────────────
_session: aiohttp.ClientSession | None = None


async def get_session() -> aiohttp.ClientSession:
    """Return (or lazily create) the shared aiohttp session."""
    global _session
    if _session is None or _session.closed:
        connector = aiohttp.TCPConnector(
            limit=20,               # max simultaneous connections
            limit_per_host=10,
            keepalive_timeout=60,
        )
        timeout = aiohttp.ClientTimeout(
            connect=_CONNECT_TIMEOUT,
            total=_TOTAL_TIMEOUT,
        )
        _session = aiohttp.ClientSession(connector=connector, timeout=timeout)
    return _session


async def close_session() -> None:
    """Close the session gracefully (called from lifespan shutdown)."""
    global _session
    if _session and not _session.closed:
        await _session.close()
    _session = None


# ── Core POST helper with retry ───────────────────────────────────────────────

async def _post_form_data(url: str, form: aiohttp.FormData, label: str) -> dict:
    """
    POST multipart/form-data to `url` with retry + backoff.
    Raises RuntimeError on final failure so callers can log a warning
    and degrade gracefully (e.g. return [] instead of crashing).
    """
    session = await get_session()
    last_exc: Exception | None = None

    for attempt in range(_MAX_RETRIES):
        try:
            async with session.post(url, data=form) as resp:
                if resp.status == 200:
                    return await resp.json()
                # 503 = space still waking up — retry
                if resp.status == 503:
                    body = await resp.text()
                    logger.warning(
                        "[space_client] %s returned 503 (attempt %d/%d): %s",
                        label, attempt + 1, _MAX_RETRIES, body[:200],
                    )
                    last_exc = RuntimeError(f"{label} 503: {body[:200]}")
                else:
                    body = await resp.text()
                    raise RuntimeError(f"{label} HTTP {resp.status}: {body[:300]}")

        except asyncio.TimeoutError as e:
            logger.warning(
                "[space_client] %s timeout (attempt %d/%d)", label, attempt + 1, _MAX_RETRIES
            )
            last_exc = e
        except aiohttp.ClientConnectionError as e:
            logger.warning(
                "[space_client] %s connection error (attempt %d/%d): %s",
                label, attempt + 1, _MAX_RETRIES, e,
            )
            last_exc = e

        # Wait before retrying (skip wait after last attempt)
        if attempt < _MAX_RETRIES - 1:
            wait = _RETRY_BACKOFF[attempt]
            logger.info("[space_client] Retrying %s in %ds…", label, wait)
            await asyncio.sleep(wait)

    raise RuntimeError(f"{label} failed after {_MAX_RETRIES} attempts: {last_exc}")


# ── Public API: embed_face ────────────────────────────────────────────────────

async def embed_face(image_bytes: bytes, quality_gate: float = 0.35) -> list[dict]:
    """
    Send image bytes to the Face Space and return a list of face dicts.
    Returns [] on failure (caller logs search.face_space_unavailable).
    """
    if not FACE_SPACE_URL:
        logger.error("[space_client] FACE_SPACE_URL is not set.")
        return []

    url = f"{FACE_SPACE_URL}/embed/face"
    try:
        form = aiohttp.FormData()
        form.add_field(
            "file",
            io.BytesIO(image_bytes),
            filename="image.jpg",
            content_type="image/jpeg",
        )
        form.add_field("quality_gate", str(quality_gate))
        result = await _post_form_data(url, form, "face-space")
        faces = result.get("faces", [])
        logger.info("[space_client] embed_face → %d face(s)", len(faces))
        return faces
    except Exception as e:
        logger.warning("[space_client] embed_face failed: %s", e)
        return []          # Degrade gracefully — search.py handles empty list


# ── Public API: embed_object ──────────────────────────────────────────────────

async def embed_object(image_bytes: bytes) -> list[dict]:
    """
    Send image bytes to the Object Space and return a list of object dicts.
    Returns [] on failure (caller raises 503 to the user).
    """
    if not OBJECT_SPACE_URL:
        logger.error("[space_client] OBJECT_SPACE_URL is not set.")
        return []

    url = f"{OBJECT_SPACE_URL}/embed/object"
    try:
        form = aiohttp.FormData()
        form.add_field(
            "file",
            io.BytesIO(image_bytes),
            filename="image.jpg",
            content_type="image/jpeg",
        )
        result = await _post_form_data(url, form, "object-space")
        objects = result.get("objects", [])
        logger.info("[space_client] embed_object → %d object vec(s)", len(objects))
        return objects
    except Exception as e:
        logger.warning("[space_client] embed_object failed: %s", e)
        return []


# ── Health check / ping ───────────────────────────────────────────────────────

async def ping_spaces() -> dict:
    """
    Ping both spaces at startup (wakes them from sleep).
    Returns {"face": bool, "object": bool}.
    """
    session = await get_session()
    results = {"face": False, "object": False}

    async def _ping(url: str, key: str) -> None:
        try:
            async with session.get(
                f"{url}/health",
                timeout=aiohttp.ClientTimeout(total=90),   # allow cold-start wake
            ) as resp:
                results[key] = resp.status == 200
                logger.info("[space_client] ping %s → %d", key, resp.status)
        except Exception as e:
            logger.warning("[space_client] ping %s failed: %s", key, e)

    ping_tasks = []
    if FACE_SPACE_URL:
        ping_tasks.append(_ping(FACE_SPACE_URL, "face"))
    if OBJECT_SPACE_URL:
        ping_tasks.append(_ping(OBJECT_SPACE_URL, "object"))

    await asyncio.gather(*ping_tasks)
    return results


# ── Keep-warm background task ─────────────────────────────────────────────────

async def keep_spaces_warm(interval_seconds: int = 540) -> None:
    """
    Ping both spaces every `interval_seconds` (default 9 min) to prevent
    HF free-tier sleep (spaces sleep after ~15 min of inactivity).

    Start this as a background task from your lifespan:
        asyncio.create_task(keep_spaces_warm())
    """
    session = await get_session()
    while True:
        await asyncio.sleep(interval_seconds)
        urls = []
        if FACE_SPACE_URL:
            urls.append((FACE_SPACE_URL, "face"))
        if OBJECT_SPACE_URL:
            urls.append((OBJECT_SPACE_URL, "object"))

        async def _warm(url: str, label: str) -> None:
            try:
                async with session.get(
                    f"{url}/health",
                    timeout=aiohttp.ClientTimeout(total=30),
                ) as resp:
                    logger.debug("[space_client] keep-warm %s → %d", label, resp.status)
            except Exception as e:
                logger.debug("[space_client] keep-warm %s error: %s", label, e)

        await asyncio.gather(*[_warm(u, l) for u, l in urls], return_exceptions=True)