Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 0 additions & 29 deletions .github/actions/format-issue-title/action.yml

This file was deleted.

50 changes: 0 additions & 50 deletions .github/actions/format-issue-title/format.sh

This file was deleted.

2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
git pull origin release

# Install dependencies (Production mode, no dev deps)
/root/.local/bin/poetry install --only main --sync
/root/.local/bin/poetry install --only main --all-extras --sync

# Restart the service
sudo systemctl restart tiny.service
37 changes: 37 additions & 0 deletions .github/workflows/main-ro-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Sync Main to Release

on:
push:
branches: [main]
workflow_dispatch:

jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Required to compare branches

- name: Check for Code Differences
id: diff_check
run: |
DIFF=$(git diff origin/release...origin/main --name-only)
if [ -z "$DIFF" ]; then
echo "No changes found between main and release. Skipping."
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "## ⏭️ Sync Skipped" >> $GITHUB_STEP_SUMMARY
echo "Main and Release are already in sync." >> $GITHUB_STEP_SUMMARY
else
echo "has_changes=true" >> $GITHUB_OUTPUT
fi

- name: Run PR Logic
if: steps.diff_check.outputs.has_changes == 'true'
uses: recursivezero/action-club/.github/actions/release-pr@main
with:
# Use your PAT here if the standard token continues to fail
github_token: ${{ secrets.PROJECT_PAT }}
1 change: 1 addition & 0 deletions .vscode/dictionaries/team-member.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
keshav
mohta
recursivezero
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ All notable changes to this repository will be documented in this file.
- Added In-Memory cache strategy
- DB dependency optional
- Change UI

## [1.0.3] Wed, Feb 18, 2026

- Added Database connection retry logic
8 changes: 4 additions & 4 deletions app/api/fast_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class PyMongoError(Exception):


from app import __version__
from app.utils import data as db_data
from app.utils import db
from app.utils.cache import get_short_from_cache, set_cache_pair
from app.utils.helper import generate_code, is_valid_url, sanitize_url

Expand Down Expand Up @@ -154,7 +154,7 @@ def shorten_url(payload: ShortenRequest):
},
)

if db_data.collection is None:
if db.collection is None:
cached_short = get_short_from_cache(original_url)
short_code = cached_short or generate_code()
set_cache_pair(short_code, original_url)
Expand All @@ -166,7 +166,7 @@ def shorten_url(payload: ShortenRequest):
}

try:
existing = db_data.collection.find_one({"original_url": original_url})
existing = db.collection.find_one({"original_url": original_url})
except PyMongoError:
existing = None

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

short_code = generate_code()
try:
db_data.collection.insert_one(
db.collection.insert_one(
{
"short_code": short_code,
"original_url": original_url,
Expand Down
98 changes: 74 additions & 24 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
import logging

from fastapi import FastAPI, Form, Request, status
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware

from app.api.fast_api import app as api_app
from app.utils import data as db_data
from app.utils import db
from app.utils.cache import (
get_from_cache,
get_recent_from_cache,
Expand All @@ -33,8 +34,26 @@
# -----------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
db_data.connect_db()
logger = logging.getLogger(__name__)
logger.info("Application startup: Connecting to database...")
db.connect_db()
db.start_health_check()
logger.info("Application startup complete")

yield

logger.info("Application shutdown: Cleaning up...")
await db.stop_health_check()

# Close MongoDB client gracefully
try:
if db.client is not None:
db.client.close()
logger.info("MongoDB client closed")
except Exception as e:
logger.error(f"Error closing MongoDB client: {str(e)}")

logger.info("Application shutdown complete")


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

all_urls = db_data.get_recent_urls(MAX_RECENT_URLS) or get_recent_from_cache(
all_urls = db.get_recent_urls(MAX_RECENT_URLS) or get_recent_from_cache(
MAX_RECENT_URLS
)

Expand All @@ -91,7 +110,7 @@ async def index(request: Request):
"original_url": original_url,
"error": error,
"info_message": info_message,
"db_available": db_data.get_collection() is not None,
"db_available": db.get_collection() is not None,
},
)

Expand All @@ -103,6 +122,8 @@ async def create_short_url(
generate_qr: Optional[str] = Form(None),
qr_type: str = Form("short"),
) -> RedirectResponse:
logger = logging.getLogger(__name__)

session = request.session
qr_enabled = bool(generate_qr)
original_url = sanitize_url(original_url)
Expand All @@ -116,25 +137,28 @@ async def create_short_url(
short_code: Optional[str] = get_short_from_cache(original_url)

if not short_code:
# 2. Try Database
existing = db_data.find_by_original_url(original_url)
# Pull the value and check it in one go
db_code = existing.get("short_code") if existing else None
if isinstance(db_code, str):
short_code = db_code
set_cache_pair(short_code, original_url) # Cache it for future requests
# 2. Try Database if connected
if db.is_connected():
existing = db.find_by_original_url(original_url)
db_code = existing.get("short_code") if existing else None
if isinstance(db_code, str):
short_code = db_code
set_cache_pair(short_code, original_url)

# 3. Generate New if still None
if not short_code:
short_code = generate_code()
set_cache_pair(short_code, original_url)
db_data.insert_url(short_code, original_url)

# Only write to database if connected
if db.is_connected():
db.insert_url(short_code, original_url)
else:
logger.warning(f"Database not connected, URL {short_code} created in cache only")
session["info_message"] = "URL created (database temporarily unavailable)"

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

Expand All @@ -156,7 +180,7 @@ async def create_short_url(

@app.get("/recent", response_class=HTMLResponse)
async def recent_urls(request: Request):
recent_urls_list = db_data.get_recent_urls(
recent_urls_list = db.get_recent_urls(
MAX_RECENT_URLS
) or get_recent_from_cache(MAX_RECENT_URLS)

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

@app.post("/delete/{short_code}")
async def delete_url(request: Request, short_code: str):
db_data.delete_by_short_code(short_code)
db.delete_by_short_code(short_code)

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

@app.get("/{short_code}")
async def redirect_short(request: Request, short_code: str):
doc = db_data.increment_visit(short_code)

logger = logging.getLogger(__name__)
# Try cache first
cached_url = get_from_cache(short_code)
if cached_url:
return RedirectResponse(cached_url)


# Check if database is connected
if not db.is_connected():
logger.warning(f"Database not connected, cannot redirect {short_code}")
return PlainTextResponse(
"Service temporarily unavailable. Please try again later.",
status_code=503,
headers={"Retry-After": "30"}
)

# Try database
doc = db.increment_visit(short_code)
if doc:
set_cache_pair(short_code, doc["original_url"])
return RedirectResponse(doc["original_url"])
if db_data.get_collection() is None:
return PlainTextResponse("Database is not connected.", status_code=503)


return PlainTextResponse("Invalid or expired short URL", status_code=404)


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


@app.get("/health")
async def health_check():
"""Health check endpoint showing database and cache status."""
state = db.get_connection_state()

response_data = {
"database": state,
"cache": {
"enabled": True,
"size": len(url_cache),
}
}

status_code = 200 if state["connected"] else 503
return JSONResponse(content=response_data, status_code=status_code)


app.mount("/api", api_app)


Expand Down
16 changes: 16 additions & 0 deletions app/utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@ def _get_int(key: str, default: int) -> int:
MONGO_DB_NAME = "tiny_url"
MONGO_COLLECTION = os.getenv("MONGO_COLLECTION", "urls")

# Connection timeouts (in milliseconds)
MONGO_TIMEOUT_MS = _get_int("MONGO_TIMEOUT_MS", 10000)
MONGO_SOCKET_TIMEOUT_MS = _get_int("MONGO_SOCKET_TIMEOUT_MS", 20000)

# Connection pool settings
MONGO_MIN_POOL_SIZE = _get_int("MONGO_MIN_POOL_SIZE", 5)
MONGO_MAX_POOL_SIZE = _get_int("MONGO_MAX_POOL_SIZE", 50)

# Retry configuration
MONGO_MAX_RETRIES = _get_int("MONGO_MAX_RETRIES", 10)
MONGO_INITIAL_RETRY_DELAY = 1.0
MONGO_MAX_RETRY_DELAY = 30.0

# Health check interval (in seconds)
HEALTH_CHECK_INTERVAL_SECONDS = _get_int("HEALTH_CHECK_INTERVAL_SECONDS", 30)


# -------------------------
# Cache (constants)
Expand Down
Loading