concurrency-fix #15
@@ -237,12 +237,12 @@ async def cleanup_stale_webhooks(
|
|||||||
update(WebhookRequest)
|
update(WebhookRequest)
|
||||||
.where(
|
.where(
|
||||||
and_(
|
and_(
|
||||||
WebhookRequest.status == 'processing',
|
WebhookRequest.status == WebhookStatus.PROCESSING,
|
||||||
WebhookRequest.processing_started_at < timeout_threshold
|
WebhookRequest.processing_started_at < timeout_threshold
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.values(
|
.values(
|
||||||
status='failed',
|
status=WebhookStatus.FAILED,
|
||||||
last_error='Processing timeout - worker may have crashed'
|
last_error='Processing timeout - worker may have crashed'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -277,7 +277,7 @@ async def purge_old_webhook_payloads(
|
|||||||
update(WebhookRequest)
|
update(WebhookRequest)
|
||||||
.where(
|
.where(
|
||||||
and_(
|
and_(
|
||||||
WebhookRequest.status == 'completed',
|
WebhookRequest.status == WebhookStatus.COMPLETED,
|
||||||
WebhookRequest.created_at < cutoff_date,
|
WebhookRequest.created_at < cutoff_date,
|
||||||
WebhookRequest.purged_at.is_(None) # Not already purged
|
WebhookRequest.purged_at.is_(None) # Not already purged
|
||||||
)
|
)
|
||||||
@@ -705,8 +705,13 @@ async def handle_webhook_unified(
|
|||||||
):
|
):
|
||||||
"""Unified webhook handler with deduplication and routing.
|
"""Unified webhook handler with deduplication and routing.
|
||||||
|
|
||||||
|
Supports both new secure webhook URLs and legacy endpoints:
|
||||||
|
- /webhook/{64-char-secret} - New secure endpoints
|
||||||
|
- /webhook/wix-form - Legacy Wix form endpoint (extracts hotel from payload)
|
||||||
|
- /webhook/generic - Legacy generic webhook endpoint (extracts hotel from payload)
|
||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
1. Look up webhook_endpoint by webhook_secret
|
1. Look up webhook_endpoint by webhook_secret (or detect legacy endpoint)
|
||||||
2. Parse and hash payload (SHA256)
|
2. Parse and hash payload (SHA256)
|
||||||
3. Check for duplicate using SELECT FOR UPDATE SKIP LOCKED
|
3. Check for duplicate using SELECT FOR UPDATE SKIP LOCKED
|
||||||
4. If duplicate and completed: return success (idempotent)
|
4. If duplicate and completed: return success (idempotent)
|
||||||
@@ -718,23 +723,7 @@ async def handle_webhook_unified(
|
|||||||
"""
|
"""
|
||||||
timestamp = datetime.now(UTC)
|
timestamp = datetime.now(UTC)
|
||||||
|
|
||||||
# 1. Look up webhook_endpoint
|
# 2. Parse payload first (needed for legacy endpoint detection)
|
||||||
result = await db_session.execute(
|
|
||||||
select(WebhookEndpoint)
|
|
||||||
.where(
|
|
||||||
and_(
|
|
||||||
WebhookEndpoint.webhook_secret == webhook_secret,
|
|
||||||
WebhookEndpoint.is_enabled == True
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.options(selectinload(WebhookEndpoint.hotel))
|
|
||||||
)
|
|
||||||
webhook_endpoint: WebhookEndpoint | None = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not webhook_endpoint or not webhook_endpoint.hotel.is_active:
|
|
||||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
|
||||||
|
|
||||||
# 2. Parse payload
|
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
|
|
||||||
# Handle gzip compression
|
# Handle gzip compression
|
||||||
@@ -751,6 +740,84 @@ async def handle_webhook_unified(
|
|||||||
_LOGGER.error("Failed to parse JSON payload: %s", e)
|
_LOGGER.error("Failed to parse JSON payload: %s", e)
|
||||||
raise HTTPException(status_code=400, detail="Invalid JSON payload")
|
raise HTTPException(status_code=400, detail="Invalid JSON payload")
|
||||||
|
|
||||||
|
# 1. Detect if this is a legacy endpoint or look up webhook_endpoint
|
||||||
|
webhook_endpoint: WebhookEndpoint | None = None
|
||||||
|
is_legacy = False
|
||||||
|
webhook_type = None
|
||||||
|
hotel_id_from_payload = None
|
||||||
|
|
||||||
|
# Check if webhook_secret looks like a legacy endpoint name
|
||||||
|
if webhook_secret in ("wix-form", "generic"):
|
||||||
|
is_legacy = True
|
||||||
|
webhook_type = "wix_form" if webhook_secret == "wix-form" else "generic"
|
||||||
|
|
||||||
|
# Extract hotel_id from payload based on webhook type
|
||||||
|
if webhook_type == "wix_form":
|
||||||
|
# Wix forms: field:hotelid or use default
|
||||||
|
hotel_id_from_payload = payload.get("data", {}).get("field:hotelid") if isinstance(payload.get("data"), dict) else payload.get("field:hotelid")
|
||||||
|
if not hotel_id_from_payload:
|
||||||
|
hotel_id_from_payload = request.app.state.config.get("default_hotel_code", "123")
|
||||||
|
_LOGGER.info("Legacy wix-form endpoint: using default hotel_code=%s", hotel_id_from_payload)
|
||||||
|
elif webhook_type == "generic":
|
||||||
|
# Generic webhooks: hotel_data.hotelcode or use default
|
||||||
|
hotel_data = payload.get("hotel_data", {})
|
||||||
|
hotel_id_from_payload = hotel_data.get("hotelcode")
|
||||||
|
if not hotel_id_from_payload:
|
||||||
|
hotel_id_from_payload = request.app.state.config.get("default_hotel_code", "123")
|
||||||
|
_LOGGER.info("Legacy generic endpoint: using default hotel_code=%s", hotel_id_from_payload)
|
||||||
|
|
||||||
|
_LOGGER.info(
|
||||||
|
"Legacy endpoint detected: %s, webhook_type=%s, hotel_id=%s",
|
||||||
|
webhook_secret,
|
||||||
|
webhook_type,
|
||||||
|
hotel_id_from_payload
|
||||||
|
)
|
||||||
|
|
||||||
|
# Look up the webhook endpoint for this hotel and type
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(WebhookEndpoint)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
WebhookEndpoint.hotel_id == hotel_id_from_payload,
|
||||||
|
WebhookEndpoint.webhook_type == webhook_type,
|
||||||
|
WebhookEndpoint.is_enabled == True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.options(selectinload(WebhookEndpoint.hotel))
|
||||||
|
)
|
||||||
|
webhook_endpoint = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not webhook_endpoint:
|
||||||
|
_LOGGER.error(
|
||||||
|
"No webhook endpoint found for legacy endpoint: hotel_id=%s, type=%s",
|
||||||
|
hotel_id_from_payload,
|
||||||
|
webhook_type
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"No webhook configuration found for hotel {hotel_id_from_payload}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# New secure endpoint - look up by webhook_secret
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(WebhookEndpoint)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
WebhookEndpoint.webhook_secret == webhook_secret,
|
||||||
|
WebhookEndpoint.is_enabled == True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.options(selectinload(WebhookEndpoint.hotel))
|
||||||
|
)
|
||||||
|
webhook_endpoint = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not webhook_endpoint:
|
||||||
|
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||||
|
|
||||||
|
# Verify hotel is active
|
||||||
|
if not webhook_endpoint.hotel.is_active:
|
||||||
|
raise HTTPException(status_code=404, detail="Hotel is not active")
|
||||||
|
|
||||||
# 3. Hash payload (canonical JSON for consistent hashing)
|
# 3. Hash payload (canonical JSON for consistent hashing)
|
||||||
payload_json_str = json.dumps(payload, sort_keys=True)
|
payload_json_str = json.dumps(payload, sort_keys=True)
|
||||||
payload_hash = hashlib.sha256(payload_json_str.encode("utf-8")).hexdigest()
|
payload_hash = hashlib.sha256(payload_json_str.encode("utf-8")).hexdigest()
|
||||||
@@ -852,7 +919,7 @@ async def handle_webhook_unified(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.exception("Error processing webhook: %s", e)
|
_LOGGER.exception("Error processing webhook: %s", e)
|
||||||
|
|
||||||
webhook_request.status = 'failed'
|
webhook_request.status = WebhookStatus.FAILED
|
||||||
webhook_request.last_error = str(e)[:2000]
|
webhook_request.last_error = str(e)[:2000]
|
||||||
webhook_request.processing_completed_at = datetime.now(UTC)
|
webhook_request.processing_completed_at = datetime.now(UTC)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|||||||
Reference in New Issue
Block a user