Spaces:
Sleeping
Sleeping
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()
|