FastAPI service that subscribes to nearle/# on the MQTT broker and forwards rider status, periodic logs, and profile updates to the Jupiter webhook API. Includes fixes for a failure mode that took the bridge down silently for ~3.8 days in June 2026, during which it served HTTP 200 on / while forwarding zero events: - Retry MQTT connection with backoff instead of letting a single failed connect kill the background thread permanently. - Subscribe inside on_connect so subscriptions are restored after an automatic reconnect; the session is clean, so a reconnect previously came back subscribed to nothing. - Add /healthz returning 503 when MQTT is disconnected, so a monitor can distinguish a live bridge from a dead one. - Replace thread-per-message forwarding with a bounded worker pool, which keeps memory flat when the upstream API stalls on its 5s timeout. - Pin requirements.txt to the versions verified in production; it was unpinned and had already drifted to paho-mqtt 2.1.0. - Set PYTHONUNBUFFERED so container logs are not block-buffered. - Name the compose image nearle-api-image to match the deployed tag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
207 lines
7.6 KiB
Python
207 lines
7.6 KiB
Python
from fastapi import FastAPI
|
|
import paho.mqtt.client as mqtt
|
|
from paho.mqtt.enums import CallbackAPIVersion
|
|
import json
|
|
import threading
|
|
import os
|
|
import time
|
|
import requests
|
|
import datetime
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
app = FastAPI(title="Nearle MQTT Webhook Bridge API")
|
|
|
|
# Configuration
|
|
MQTT_BROKER = os.getenv("MQTT_BROKER", "66.116.225.226")
|
|
MQTT_PORT = int(os.getenv("MQTT_PORT", "1883"))
|
|
MQTT_USER = os.getenv("MQTT_USER", "admin")
|
|
MQTT_PASS = os.getenv("MQTT_PASS", "Package@321#")
|
|
MQTT_TOPIC = os.getenv("MQTT_TOPIC", "nearle/#")
|
|
|
|
# Define your specific external POST APIs here
|
|
API_STATUS_URL = os.getenv("API_STATUS_URL", "https://jupiter.nearle.app/live/api/v1/utils/createriderstatus")
|
|
API_LOGS_URL = os.getenv("API_LOGS_URL", "https://jupiter.nearle.app/live/api/v1/utils/createriderperiodiclog")
|
|
API_PROFILE_URL = os.getenv("API_PROFILE_URL", "https://jupiter.nearle.app/live/api/v1/utils/createuserredis")
|
|
|
|
# Bounded worker pool. Previously every MQTT message spawned its own thread, so a
|
|
# stall on the Jupiter API (the 5s read timeouts seen in the logs) let threads pile
|
|
# up without limit. A fixed pool keeps memory flat and applies natural backpressure.
|
|
FORWARD_WORKERS = int(os.getenv("FORWARD_WORKERS", "16"))
|
|
_executor = ThreadPoolExecutor(max_workers=FORWARD_WORKERS, thread_name_prefix="forward")
|
|
|
|
# Liveness state, exposed on / so a monitor can tell a live bridge from a dead one.
|
|
_state = {
|
|
"mqtt_connected": False,
|
|
"last_connect_at": None,
|
|
"last_disconnect_at": None,
|
|
"last_message_at": None,
|
|
"connect_attempts": 0,
|
|
"messages_received": 0,
|
|
}
|
|
_state_lock = threading.Lock()
|
|
|
|
|
|
def _now():
|
|
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
|
|
|
|
# Function to forward data to a specific URL
|
|
def forward_to_api(target_url: str, payload_str: str):
|
|
if not target_url:
|
|
return
|
|
|
|
# Determine the content type and prepare data
|
|
headers = {}
|
|
|
|
# Check if the payload is JSON or plain string
|
|
try:
|
|
payload_data = json.loads(payload_str)
|
|
headers['Content-Type'] = 'application/json'
|
|
|
|
# We send the exact parsed JSON so the receiving API sees it cleanly
|
|
data_kwargs = {"json": payload_data}
|
|
except:
|
|
# If it's a plain string like "Offline", send as text/plain
|
|
headers['Content-Type'] = 'text/plain'
|
|
data_kwargs = {"data": payload_str}
|
|
|
|
try:
|
|
response = requests.post(target_url, headers=headers, timeout=5, **data_kwargs)
|
|
if response.status_code in (200, 201):
|
|
print(f"SUCCESS ({response.status_code}) -> {target_url} | Sent: {data_kwargs}", flush=True)
|
|
else:
|
|
print(f"WARNING: HTTP {response.status_code} from {target_url} | Response: {response.text}", flush=True)
|
|
except Exception as e:
|
|
print(f"ERROR: Failed to send to {target_url} | Exception: {e}", flush=True)
|
|
|
|
|
|
def route_payload(data_type: str, rider_id: str, payload_str: str):
|
|
payload_str = payload_str.strip()
|
|
if data_type == "status":
|
|
# Ensure userid is an integer if possible so Jupiter API doesn't reject it
|
|
r_id = int(rider_id) if rider_id.isdigit() else rider_id
|
|
status_json = json.dumps({"userid": r_id, "status": payload_str})
|
|
forward_to_api(API_STATUS_URL, status_json)
|
|
elif data_type == "logs":
|
|
forward_to_api(API_LOGS_URL, payload_str)
|
|
elif data_type == "profile":
|
|
forward_to_api(API_PROFILE_URL, payload_str)
|
|
else:
|
|
# Ignore other data types or add additional routes here
|
|
pass
|
|
|
|
|
|
# MQTT Callbacks
|
|
def on_connect(client, userdata, connect_flags, reason_code, properties=None):
|
|
if reason_code == 0:
|
|
with _state_lock:
|
|
_state["mqtt_connected"] = True
|
|
_state["last_connect_at"] = _now()
|
|
# Subscribing here (not once after connect) is the important part: the
|
|
# session is clean, so every reconnect starts with no subscriptions. Doing
|
|
# it in on_connect means the bridge resubscribes automatically after any
|
|
# broker restart or network blip instead of sitting connected and deaf.
|
|
client.subscribe(MQTT_TOPIC)
|
|
print(f"MQTT connected to {MQTT_BROKER}:{MQTT_PORT}, subscribed to {MQTT_TOPIC}", flush=True)
|
|
else:
|
|
print(f"MQTT connect refused: {reason_code}", flush=True)
|
|
|
|
|
|
def on_disconnect(client, userdata, disconnect_flags, reason_code, properties=None):
|
|
with _state_lock:
|
|
_state["mqtt_connected"] = False
|
|
_state["last_disconnect_at"] = _now()
|
|
print(f"MQTT disconnected: {reason_code} (will auto-reconnect)", flush=True)
|
|
|
|
|
|
def on_message(client, userdata, msg):
|
|
try:
|
|
topic = msg.topic
|
|
payload = msg.payload.decode()
|
|
|
|
with _state_lock:
|
|
_state["last_message_at"] = _now()
|
|
_state["messages_received"] += 1
|
|
|
|
# Topic structure: nearle/riders/{id}/{data_type}/...
|
|
parts = topic.split('/')
|
|
if len(parts) >= 4 and parts[0] == "nearle" and parts[1] == "riders":
|
|
rider_id = parts[2]
|
|
data_type = parts[3]
|
|
|
|
# Hand off to the pool so the MQTT network loop is never blocked
|
|
_executor.submit(route_payload, data_type, rider_id, payload)
|
|
|
|
except Exception as e:
|
|
print(f"Error processing MQTT message: {e}", flush=True)
|
|
|
|
|
|
def start_mqtt():
|
|
# This loop is why the bridge no longer dies silently. The old version called
|
|
# connect() inside a try/except: one failure ("No route to host" on 2026-06-10)
|
|
# killed the thread for good while uvicorn kept answering 200 OK on /, so the
|
|
# bridge looked healthy while forwarding nothing until someone restarted it.
|
|
backoff = 5
|
|
while True:
|
|
try:
|
|
with _state_lock:
|
|
_state["connect_attempts"] += 1
|
|
|
|
client = mqtt.Client(CallbackAPIVersion.VERSION2)
|
|
client.username_pw_set(MQTT_USER, MQTT_PASS)
|
|
client.on_connect = on_connect
|
|
client.on_disconnect = on_disconnect
|
|
client.on_message = on_message
|
|
client.reconnect_delay_set(min_delay=1, max_delay=60)
|
|
|
|
print(f"Connecting to MQTT Broker at {MQTT_BROKER}...", flush=True)
|
|
|
|
# connect_async + retry_first_connection means a broker that is down at
|
|
# startup is retried instead of being a fatal error.
|
|
client.connect_async(MQTT_BROKER, MQTT_PORT, keepalive=60)
|
|
backoff = 5
|
|
client.loop_forever(retry_first_connection=True)
|
|
|
|
print("MQTT loop returned unexpectedly; restarting.", flush=True)
|
|
except Exception as e:
|
|
print(f"MQTT loop crashed: {e} | retrying in {backoff}s", flush=True)
|
|
finally:
|
|
with _state_lock:
|
|
_state["mqtt_connected"] = False
|
|
|
|
time.sleep(backoff)
|
|
backoff = min(backoff * 2, 60)
|
|
|
|
|
|
# Start MQTT in a background thread
|
|
@app.on_event("startup")
|
|
async def startup_event():
|
|
thread = threading.Thread(target=start_mqtt, daemon=True)
|
|
thread.start()
|
|
|
|
|
|
# simple health check endpoint
|
|
@app.get("/")
|
|
def read_root():
|
|
with _state_lock:
|
|
snapshot = dict(_state)
|
|
return {
|
|
"status": "Nearle Webhook Bridge is running",
|
|
"description": "Listening to MQTT and forwarding to external POST APIs.",
|
|
"mqtt": snapshot,
|
|
}
|
|
|
|
|
|
@app.get("/healthz")
|
|
def healthz():
|
|
"""Returns 200 only when the MQTT side is actually connected, so an external
|
|
monitor can detect a bridge that is up but not receiving anything."""
|
|
with _state_lock:
|
|
connected = _state["mqtt_connected"]
|
|
snapshot = dict(_state)
|
|
from fastapi.responses import JSONResponse
|
|
return JSONResponse(
|
|
status_code=200 if connected else 503,
|
|
content={"healthy": connected, "mqtt": snapshot},
|
|
)
|