File size: 11,276 Bytes
1ebb69b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b9345ee
 
 
 
 
 
 
 
1ebb69b
b9345ee
1ebb69b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
"""
Nancy β€” Upstash Redis REST API client.

Uses ``httpx`` to communicate with Upstash Redis via its REST interface.
All operations are optional β€” if Redis is not configured, methods are
no-ops that return sensible defaults.

This allows the HF Space to work without any external dependencies while
supporting persistence when Upstash credentials are provided.
"""

from __future__ import annotations

import json
import logging
from typing import Any

import httpx

from config import settings

logger = logging.getLogger("nancy.redis")


class RedisClient:
    """
    Async client for the Upstash Redis REST API.

    All public methods are safe to call even when Redis is not configured β€”
    they will log a debug message and return ``None`` / empty defaults.

    Usage::

        redis = RedisClient()
        await redis.set("key", "value", ex=300)
        val = await redis.get("key")
    """

    def __init__(self) -> None:
        self._enabled = settings.redis_enabled
        self._base_url = settings.upstash_redis_rest_url.rstrip("/")
        self._token = settings.upstash_redis_rest_token
        self._client: httpx.AsyncClient | None = None

    @property
    def is_enabled(self) -> bool:
        """Return True if Redis is configured and the client is initialized."""
        return self._enabled and self._client is not None


    async def startup(self) -> None:
        """Initialize the HTTP client. Call during app startup."""
        if not self._enabled:
            logger.info("Redis not configured β€” using in-memory fallbacks.")
            return
        import base64
        import os

        # Determine authentication headers (Upstash Bearer vs Self-hosted Webdis Basic Auth)
        redis_secret = os.getenv("NANCY_REDIS_SECRET", "")
        if self._token:
            headers = {"Authorization": f"Bearer {self._token}"}
            logger.info("Configuring REST client using Upstash Bearer token.")
        elif redis_secret:
            auth_str = f"nancy_admin:{redis_secret}"
            b64_auth = base64.b64encode(auth_str.encode("utf-8")).decode("utf-8")
            headers = {"Authorization": f"Basic {b64_auth}"}
            logger.info("Configuring REST client using Self-Hosted Webdis Basic Auth.")
        else:
            headers = {}
            logger.warning("No authentication credentials found for REST client.")

        self._client = httpx.AsyncClient(
            base_url=self._base_url,
            headers=headers,
            timeout=httpx.Timeout(10.0, connect=5.0),
        )
        # Verify connectivity
        try:
            resp = await self._client.post("/", json=["PING"])
            resp.raise_for_status()
            logger.info("βœ… Redis connected successfully: %s", resp.json())
        except httpx.ConnectError as exc:
            logger.error("❌ Redis Space connection failed! It may be sleeping/hibernating. (Error: %s)", exc)
        except httpx.HTTPStatusError as exc:
            if exc.response.status_code in (401, 403):
                logger.error("❌ Redis Authentication failed! Check NANCY_REDIS_SECRET or token. (HTTP %s)", exc.response.status_code)
            else:
                logger.warning("⚠️ Redis PING failed with HTTP %s: %s", exc.response.status_code, exc.response.text)
        except Exception as exc:
            logger.warning("⚠️ Redis PING failed (non-fatal): %s", exc)

    async def shutdown(self) -> None:
        """Close the HTTP client. Call during app shutdown."""
        if self._client:
            await self._client.aclose()
            self._client = None

    # ── Low-level command execution ───────────────────────────────────

    async def _execute(self, *args: str) -> Any:
        """
        Execute a raw Redis command via the REST API.

        Returns the ``result`` field from the Upstash response, or ``None``
        on error / when Redis is disabled.
        """
        if not self._enabled or not self._client:
            return None

        import os
        is_webdis = os.getenv("NANCY_REDIS_SECRET", "") and not settings.upstash_redis_rest_token

        if not is_webdis:
            # Traditional Upstash REST API call
            try:
                resp = await self._client.post("/", json=list(args))
                resp.raise_for_status()
                data = resp.json()
                return data.get("result")
            except httpx.HTTPStatusError as exc:
                logger.error("Redis HTTP error: %s %s", exc.response.status_code, exc.response.text)
                return None
            except Exception as exc:
                logger.error("Redis error: %s", exc)
                return None

        # Webdis REST API Translation logic
        try:
            if not args:
                return None

            cmd = args[0].upper()

            # Special case: SET with or without EX
            if cmd == "SET":
                key = args[1]
                value = args[2]
                ex = None
                if len(args) > 4 and args[3].upper() == "EX":
                    ex = args[4]

                # Use PUT to pass large/complex value safely in the body
                resp = await self._client.put(f"/SET/{key}", content=value)
                resp.raise_for_status()

                if ex is not None:
                    # Set expire separately
                    exp_resp = await self._client.post(f"/EXPIRE/{key}/{ex}")
                    exp_resp.raise_for_status()

                return "OK"

            # Special case: HSET
            elif cmd == "HSET":
                key = args[1]
                field = args[2]
                value = args[3]
                import urllib.parse
                safe_field = urllib.parse.quote(field, safe="")
                resp = await self._client.put(f"/HSET/{key}/{safe_field}", content=value)
                resp.raise_for_status()
                return 1

            # Special case: LPUSH / RPUSH
            elif cmd in ("LPUSH", "RPUSH"):
                key = args[1]
                value = args[2]
                resp = await self._client.put(f"/{cmd}/{key}", content=value)
                resp.raise_for_status()
                data = resp.json()
                return data.get(cmd)

            # Special case: SADD / SREM / SISMEMBER
            elif cmd in ("SADD", "SREM", "SISMEMBER"):
                key = args[1]
                value = args[2]
                resp = await self._client.put(f"/{cmd}/{key}", content=value)
                resp.raise_for_status()
                data = resp.json()
                res = data.get(cmd)
                if res is None:
                    res = data.get(cmd.lower())
                return res

            else:
                # Fallback for standard commands: urlencode arguments in the path
                import urllib.parse
                encoded_args = [urllib.parse.quote(str(arg), safe="") for arg in args[1:]]
                if encoded_args:
                    path = f"/{cmd}/" + "/".join(encoded_args)
                else:
                    path = f"/{cmd}"

                # Execute via GET
                resp = await self._client.get(path)
                resp.raise_for_status()
                data = resp.json()

                res = data.get(cmd)
                if res is None:
                    res = data.get(cmd.lower())
                return res

        except httpx.HTTPStatusError as exc:
            logger.error("Webdis Redis HTTP error: %s %s", exc.response.status_code, exc.response.text)
            return None
        except Exception as exc:
            logger.error("Webdis Redis error: %s", exc)
            return None

    # ── High-level operations ─────────────────────────────────────────

    async def get(self, key: str) -> str | None:
        """Get a string value by key."""
        return await self._execute("GET", key)

    async def set(
        self,
        key: str,
        value: str,
        ex: int | None = None,
    ) -> bool:
        """
        Set a string value, optionally with expiration in seconds.

        Returns True on success.
        """
        if ex is not None:
            result = await self._execute("SET", key, value, "EX", str(ex))
        else:
            result = await self._execute("SET", key, value)
        return result == "OK"

    async def delete(self, key: str) -> bool:
        """Delete a key. Returns True if the key existed."""
        result = await self._execute("DEL", key)
        return result is not None and int(result) > 0

    async def incr(self, key: str) -> int | None:
        """Increment an integer key. Returns the new value."""
        result = await self._execute("INCR", key)
        return int(result) if result is not None else None

    async def expire(self, key: str, seconds: int) -> bool:
        """Set expiration on an existing key."""
        result = await self._execute("EXPIRE", key, str(seconds))
        return result is not None and int(result) == 1

    async def lpush(self, key: str, value: str) -> int | None:
        """Push a value to the head of a list."""
        result = await self._execute("LPUSH", key, value)
        return int(result) if result is not None else None

    async def lrange(self, key: str, start: int, stop: int) -> list[str]:
        """Return a range of elements from a list."""
        result = await self._execute("LRANGE", key, str(start), str(stop))
        return result if isinstance(result, list) else []

    async def hset(self, key: str, field: str, value: str) -> bool:
        """Set a hash field."""
        result = await self._execute("HSET", key, field, value)
        return result is not None

    async def hget(self, key: str, field: str) -> str | None:
        """Get a hash field value."""
        return await self._execute("HGET", key, field)

    async def hgetall(self, key: str) -> dict[str, str]:
        """Get all fields and values in a hash."""
        result = await self._execute("HGETALL", key)
        if not result or not isinstance(result, list):
            return {}
        # Upstash returns [field1, val1, field2, val2, ...]
        it = iter(result)
        return dict(zip(it, it))

    # ── JSON helpers ──────────────────────────────────────────────────

    async def set_json(
        self,
        key: str,
        value: Any,
        ex: int | None = None,
    ) -> bool:
        """Serialize ``value`` as JSON and store it."""
        return await self.set(key, json.dumps(value, default=str), ex=ex)

    async def get_json(self, key: str) -> Any | None:
        """Retrieve and deserialize a JSON value."""
        raw = await self.get(key)
        if raw is None:
            return None
        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            logger.warning("Failed to parse JSON for key '%s'", key)
            return None


# Module-level singleton
redis_client = RedisClient()