Skip to content

Commit 43590b1

Browse files
Merge pull request #24 from recursivezero/main
🚀 Release: 2026-02-18
2 parents 22f0ec8 + d3387a0 commit 43590b1

File tree

13 files changed

+550
-343
lines changed

13 files changed

+550
-343
lines changed

.github/actions/format-issue-title/action.yml

Lines changed: 0 additions & 29 deletions
This file was deleted.

.github/actions/format-issue-title/format.sh

Lines changed: 0 additions & 50 deletions
This file was deleted.

.github/workflows/deploy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
git pull origin release
2323
2424
# Install dependencies (Production mode, no dev deps)
25-
/root/.local/bin/poetry install --only main --sync
25+
/root/.local/bin/poetry install --only main --all-extras --sync
2626
2727
# Restart the service
2828
sudo systemctl restart tiny.service
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: Sync Main to Release
2+
3+
on:
4+
push:
5+
branches: [main]
6+
workflow_dispatch:
7+
8+
jobs:
9+
sync:
10+
runs-on: ubuntu-latest
11+
permissions:
12+
contents: write
13+
pull-requests: write
14+
steps:
15+
- uses: actions/checkout@v4
16+
with:
17+
fetch-depth: 0 # Required to compare branches
18+
19+
- name: Check for Code Differences
20+
id: diff_check
21+
run: |
22+
DIFF=$(git diff origin/release...origin/main --name-only)
23+
if [ -z "$DIFF" ]; then
24+
echo "No changes found between main and release. Skipping."
25+
echo "has_changes=false" >> $GITHUB_OUTPUT
26+
echo "## ⏭️ Sync Skipped" >> $GITHUB_STEP_SUMMARY
27+
echo "Main and Release are already in sync." >> $GITHUB_STEP_SUMMARY
28+
else
29+
echo "has_changes=true" >> $GITHUB_OUTPUT
30+
fi
31+
32+
- name: Run PR Logic
33+
if: steps.diff_check.outputs.has_changes == 'true'
34+
uses: recursivezero/action-club/.github/actions/release-pr@main
35+
with:
36+
# Use your PAT here if the standard token continues to fail
37+
github_token: ${{ secrets.PROJECT_PAT }}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
keshav
22
mohta
3+
recursivezero

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,7 @@ All notable changes to this repository will be documented in this file.
1717
- Added In-Memory cache strategy
1818
- DB dependency optional
1919
- Change UI
20+
21+
## [1.0.3] Wed, Feb 18, 2026
22+
23+
- Added Database connection retry logic

app/api/fast_api.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class PyMongoError(Exception):
2020

2121

2222
from app import __version__
23-
from app.utils import data as db_data
23+
from app.utils import db
2424
from app.utils.cache import get_short_from_cache, set_cache_pair
2525
from app.utils.helper import generate_code, is_valid_url, sanitize_url
2626

@@ -154,7 +154,7 @@ def shorten_url(payload: ShortenRequest):
154154
},
155155
)
156156

157-
if db_data.collection is None:
157+
if db.collection is None:
158158
cached_short = get_short_from_cache(original_url)
159159
short_code = cached_short or generate_code()
160160
set_cache_pair(short_code, original_url)
@@ -166,7 +166,7 @@ def shorten_url(payload: ShortenRequest):
166166
}
167167

168168
try:
169-
existing = db_data.collection.find_one({"original_url": original_url})
169+
existing = db.collection.find_one({"original_url": original_url})
170170
except PyMongoError:
171171
existing = None
172172

@@ -180,7 +180,7 @@ def shorten_url(payload: ShortenRequest):
180180

181181
short_code = generate_code()
182182
try:
183-
db_data.collection.insert_one(
183+
db.collection.insert_one(
184184
{
185185
"short_code": short_code,
186186
"original_url": original_url,

app/main.py

Lines changed: 74 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
from contextlib import asynccontextmanager
22
from pathlib import Path
33
from typing import Optional
4+
import logging
45

56
from fastapi import FastAPI, Form, Request, status
6-
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
7+
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, JSONResponse
78
from fastapi.staticfiles import StaticFiles
89
from fastapi.templating import Jinja2Templates
910
from starlette.middleware.sessions import SessionMiddleware
1011

1112
from app.api.fast_api import app as api_app
12-
from app.utils import data as db_data
13+
from app.utils import db
1314
from app.utils.cache import (
1415
get_from_cache,
1516
get_recent_from_cache,
@@ -33,8 +34,26 @@
3334
# -----------------------------
3435
@asynccontextmanager
3536
async def lifespan(app: FastAPI):
36-
db_data.connect_db()
37+
logger = logging.getLogger(__name__)
38+
logger.info("Application startup: Connecting to database...")
39+
db.connect_db()
40+
db.start_health_check()
41+
logger.info("Application startup complete")
42+
3743
yield
44+
45+
logger.info("Application shutdown: Cleaning up...")
46+
await db.stop_health_check()
47+
48+
# Close MongoDB client gracefully
49+
try:
50+
if db.client is not None:
51+
db.client.close()
52+
logger.info("MongoDB client closed")
53+
except Exception as e:
54+
logger.error(f"Error closing MongoDB client: {str(e)}")
55+
56+
logger.info("Application shutdown complete")
3857

3958

4059
app = FastAPI(title="TinyURL", lifespan=lifespan)
@@ -75,7 +94,7 @@ async def index(request: Request):
7594
generate_qr_with_logo(qr_data, str(qr_dir / qr_filename))
7695
qr_image = f"/static/qr/{qr_filename}"
7796

78-
all_urls = db_data.get_recent_urls(MAX_RECENT_URLS) or get_recent_from_cache(
97+
all_urls = db.get_recent_urls(MAX_RECENT_URLS) or get_recent_from_cache(
7998
MAX_RECENT_URLS
8099
)
81100

@@ -91,7 +110,7 @@ async def index(request: Request):
91110
"original_url": original_url,
92111
"error": error,
93112
"info_message": info_message,
94-
"db_available": db_data.get_collection() is not None,
113+
"db_available": db.get_collection() is not None,
95114
},
96115
)
97116

@@ -103,6 +122,8 @@ async def create_short_url(
103122
generate_qr: Optional[str] = Form(None),
104123
qr_type: str = Form("short"),
105124
) -> RedirectResponse:
125+
logger = logging.getLogger(__name__)
126+
106127
session = request.session
107128
qr_enabled = bool(generate_qr)
108129
original_url = sanitize_url(original_url)
@@ -116,25 +137,28 @@ async def create_short_url(
116137
short_code: Optional[str] = get_short_from_cache(original_url)
117138

118139
if not short_code:
119-
# 2. Try Database
120-
existing = db_data.find_by_original_url(original_url)
121-
# Pull the value and check it in one go
122-
db_code = existing.get("short_code") if existing else None
123-
if isinstance(db_code, str):
124-
short_code = db_code
125-
set_cache_pair(short_code, original_url) # Cache it for future requests
140+
# 2. Try Database if connected
141+
if db.is_connected():
142+
existing = db.find_by_original_url(original_url)
143+
db_code = existing.get("short_code") if existing else None
144+
if isinstance(db_code, str):
145+
short_code = db_code
146+
set_cache_pair(short_code, original_url)
126147

127148
# 3. Generate New if still None
128149
if not short_code:
129150
short_code = generate_code()
130151
set_cache_pair(short_code, original_url)
131-
db_data.insert_url(short_code, original_url)
152+
153+
# Only write to database if connected
154+
if db.is_connected():
155+
db.insert_url(short_code, original_url)
156+
else:
157+
logger.warning(f"Database not connected, URL {short_code} created in cache only")
158+
session["info_message"] = "URL created (database temporarily unavailable)"
132159

133160
# --- TYPE GUARD FOR MYPY ---
134-
# At this point, short_code could still technically be Optional[str]
135-
# if generate_code() wasn't strictly typed. We cast or assert.
136161
if not isinstance(short_code, str):
137-
# This acts as a final safety net for production
138162
session["error"] = "Internal server error: Code generation failed."
139163
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
140164

@@ -156,7 +180,7 @@ async def create_short_url(
156180

157181
@app.get("/recent", response_class=HTMLResponse)
158182
async def recent_urls(request: Request):
159-
recent_urls_list = db_data.get_recent_urls(
183+
recent_urls_list = db.get_recent_urls(
160184
MAX_RECENT_URLS
161185
) or get_recent_from_cache(MAX_RECENT_URLS)
162186

@@ -183,7 +207,7 @@ async def recent_urls(request: Request):
183207

184208
@app.post("/delete/{short_code}")
185209
async def delete_url(request: Request, short_code: str):
186-
db_data.delete_by_short_code(short_code)
210+
db.delete_by_short_code(short_code)
187211

188212
cached = url_cache.pop(short_code, None)
189213
if cached:
@@ -194,18 +218,27 @@ async def delete_url(request: Request, short_code: str):
194218

195219
@app.get("/{short_code}")
196220
async def redirect_short(request: Request, short_code: str):
197-
doc = db_data.increment_visit(short_code)
198-
221+
logger = logging.getLogger(__name__)
222+
# Try cache first
199223
cached_url = get_from_cache(short_code)
200224
if cached_url:
201225
return RedirectResponse(cached_url)
202-
226+
227+
# Check if database is connected
228+
if not db.is_connected():
229+
logger.warning(f"Database not connected, cannot redirect {short_code}")
230+
return PlainTextResponse(
231+
"Service temporarily unavailable. Please try again later.",
232+
status_code=503,
233+
headers={"Retry-After": "30"}
234+
)
235+
236+
# Try database
237+
doc = db.increment_visit(short_code)
203238
if doc:
204239
set_cache_pair(short_code, doc["original_url"])
205240
return RedirectResponse(doc["original_url"])
206-
if db_data.get_collection() is None:
207-
return PlainTextResponse("Database is not connected.", status_code=503)
208-
241+
209242
return PlainTextResponse("Invalid or expired short URL", status_code=404)
210243

211244

@@ -214,6 +247,23 @@ async def coming_soon(request: Request):
214247
return templates.TemplateResponse("coming-soon.html", {"request": request})
215248

216249

250+
@app.get("/health")
251+
async def health_check():
252+
"""Health check endpoint showing database and cache status."""
253+
state = db.get_connection_state()
254+
255+
response_data = {
256+
"database": state,
257+
"cache": {
258+
"enabled": True,
259+
"size": len(url_cache),
260+
}
261+
}
262+
263+
status_code = 200 if state["connected"] else 503
264+
return JSONResponse(content=response_data, status_code=status_code)
265+
266+
217267
app.mount("/api", api_app)
218268

219269

app/utils/config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,22 @@ def _get_int(key: str, default: int) -> int:
4141
MONGO_DB_NAME = "tiny_url"
4242
MONGO_COLLECTION = os.getenv("MONGO_COLLECTION", "urls")
4343

44+
# Connection timeouts (in milliseconds)
45+
MONGO_TIMEOUT_MS = _get_int("MONGO_TIMEOUT_MS", 10000)
46+
MONGO_SOCKET_TIMEOUT_MS = _get_int("MONGO_SOCKET_TIMEOUT_MS", 20000)
47+
48+
# Connection pool settings
49+
MONGO_MIN_POOL_SIZE = _get_int("MONGO_MIN_POOL_SIZE", 5)
50+
MONGO_MAX_POOL_SIZE = _get_int("MONGO_MAX_POOL_SIZE", 50)
51+
52+
# Retry configuration
53+
MONGO_MAX_RETRIES = _get_int("MONGO_MAX_RETRIES", 10)
54+
MONGO_INITIAL_RETRY_DELAY = 1.0
55+
MONGO_MAX_RETRY_DELAY = 30.0
56+
57+
# Health check interval (in seconds)
58+
HEALTH_CHECK_INTERVAL_SECONDS = _get_int("HEALTH_CHECK_INTERVAL_SECONDS", 30)
59+
4460

4561
# -------------------------
4662
# Cache (constants)

0 commit comments

Comments
 (0)