File size: 5,409 Bytes
f500072
 
 
 
 
 
d24b0ed
f500072
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d24b0ed
 
 
 
 
 
 
 
f500072
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import secrets
import logging
import redis.exceptions

from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel, field_validator

from .redis_client import get_redis, get_pool_stats
from .rate_limiter import (
    check_post_rate_limit,
    check_get_rate_limit,
    get_client_ip,
)
from .exceptions import (
    redis_connection_error_handler,
    redis_timeout_error_handler,
    redis_error_handler,
)

logging.basicConfig(
    level=logging.WARNING,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger(__name__)

TTL_OPTIONS = {
    "10m": 60 * 10,
    "1h": 60 * 60,
}

HEALTH_CHECK_KEY = "health:probe"


@asynccontextmanager
async def lifespan(app: FastAPI):
    """
    Initialise the Redis connection pool on startup rather than at import time.
    This ensures /health can respond even if Redis is down at boot.
    """
    try:
        r = get_redis()
        r.ping()
    except redis.exceptions.ConnectionError:
        logger.warning("startup: Redis unreachable — service will degrade gracefully")
    yield


app = FastAPI(
    title="Pastebin API",
    description="A simple pastebin service with rate limiting.",
    version="0.1.3",
    lifespan=lifespan,
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://pastebin.ysinghc.in"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.add_exception_handler(
    redis.exceptions.ConnectionError, redis_connection_error_handler
)
app.add_exception_handler(redis.exceptions.TimeoutError, redis_timeout_error_handler)
app.add_exception_handler(redis.exceptions.RedisError, redis_error_handler)


@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
    detail = exc.detail
    if isinstance(detail, str):
        detail = {"error": "http_error", "message": detail}
    headers = getattr(exc, "headers", None) or {}
    return JSONResponse(status_code=exc.status_code, content=detail, headers=headers)


class PasteRequest(BaseModel):
    content: str
    ttl: str = "10m"

    @field_validator("content")
    @classmethod
    def content_not_empty(cls, v):
        if not v or not v.strip():
            raise ValueError("content cannot be empty")
        return v

    @field_validator("ttl")
    @classmethod
    def ttl_valid(cls, v):
        if v not in TTL_OPTIONS:
            raise ValueError(f"ttl must be one of {list(TTL_OPTIONS.keys())}")
        return v


@app.get("/")
def welcome():
    return {
        "message": (
            "Welcome to the Pastebin API! "
            "POST /paste to create, GET /paste/{id} to retrieve, "
            "GET /health for status."
        )
    }


@app.get("/health")
def health():
    r = get_redis()
    try:
        r.ping()

        probe_value = secrets.token_hex(4)
        r.set(HEALTH_CHECK_KEY, probe_value, ex=30)
        retrieved = r.get(HEALTH_CHECK_KEY)
        if retrieved != probe_value:
            raise ValueError("Health probe read-back mismatch")

        return {"status": "ok", "redis": "connected", **get_pool_stats()}

    except redis.exceptions.ConnectionError:
        return JSONResponse(
            status_code=503,
            content={
                "status": "degraded",
                "redis": "unreachable",
                "possible_cause": (
                    "Connection refused — check Redis Cloud dashboard "
                    "for network quota status."
                ),
                **get_pool_stats(),
            },
        )
    except redis.exceptions.RedisError as e:
        return JSONResponse(
            status_code=503,
            content={"status": "degraded", "redis": str(e), **get_pool_stats()},
        )
    except ValueError as e:
        logger.warning("health: probe failed — %s", e)
        return JSONResponse(
            status_code=503,
            content={"status": "degraded", "redis": str(e), **get_pool_stats()},
        )


@app.post("/paste", status_code=201)
def create_paste(req: PasteRequest, request: Request, response: Response):
    r = get_redis()
    ip = get_client_ip(request)
    content_bytes = len(req.content.encode("utf-8"))

    rl_headers = check_post_rate_limit(r, ip, content_bytes)
    for header, value in rl_headers.items():
        response.headers[header] = value

    paste_id = secrets.token_urlsafe(8)
    key = f"paste:{paste_id}"
    r.set(key, req.content, ex=TTL_OPTIONS[req.ttl], nx=True)
    return {"id": paste_id, "expires_in": req.ttl, "size_bytes": content_bytes}


@app.get("/paste/{paste_id}")
def get_paste(paste_id: str, request: Request, response: Response):
    r = get_redis()
    ip = get_client_ip(request)

    rl_headers = check_get_rate_limit(r, ip)
    for header, value in rl_headers.items():
        response.headers[header] = value

    key = f"paste:{paste_id}"
    
    pipe = r.pipeline()
    pipe.get(key)
    pipe.ttl(key)
    content, ttl_remaining = pipe.execute()

    if content is None:
        raise HTTPException(
            status_code=404,
            detail={"error": "not_found", "message": "Paste not found or has expired."},
        )

    return {"content": content, "ttl_remaining_seconds": ttl_remaining}