feat: Enhance Booklooker client with webhook signature verification, idempotency handling, and retry logic
This commit is contained in:
parent
1d8ee1bba6
commit
f2e5774204
6 changed files with 232 additions and 50 deletions
|
|
@ -2,18 +2,21 @@ from __future__ import annotations
|
|||
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
|
||||
from booklooker_client import BooklookerConfig, BooklookerWebhookHelper, SyncBooklookerClient
|
||||
|
||||
|
||||
app = FastAPI(title="Booklooker webhook receiver")
|
||||
helper = BooklookerWebhookHelper()
|
||||
helper = BooklookerWebhookHelper(webhook_secret=os.environ.get("BOOKLOOKER_WEBHOOK_SECRET"))
|
||||
client = SyncBooklookerClient(BooklookerConfig(api_key=os.environ.get("BOOKLOOKER_API_KEY", "REPLACE_ME")))
|
||||
|
||||
|
||||
@app.post("/webhooks/booklooker")
|
||||
async def receive_booklooker_webhook(request: Request) -> dict:
|
||||
raw_body = await request.body()
|
||||
if not helper.validate_request(raw_body, request.headers):
|
||||
raise HTTPException(status_code=401, detail="Invalid webhook signature")
|
||||
payload = await request.json()
|
||||
event = helper.enrich_with_client(payload, client)
|
||||
return {"accepted": True, "event": event.model_dump(mode="json")}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
|
|
@ -49,6 +51,10 @@ def _iter_file(path: Path, chunk_size: int = 65536) -> Iterable[bytes]:
|
|||
yield chunk
|
||||
|
||||
|
||||
def _backoff_delay(base_delay: float, attempt: int) -> float:
|
||||
return max(0.0, base_delay * attempt)
|
||||
|
||||
|
||||
def _parse_article_list(raw: Any, field: ArticleField) -> ArticleList:
|
||||
if raw in (None, ""):
|
||||
return ArticleList(items=[], field=field, raw=raw)
|
||||
|
|
@ -153,6 +159,9 @@ class _SyncClientBase:
|
|||
token = self._get_token()
|
||||
request_params.setdefault("token", token.token)
|
||||
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, self.config.max_retries + 2):
|
||||
try:
|
||||
response = self._http.request(method, path, params=request_params, headers=headers, content=content)
|
||||
response.raise_for_status()
|
||||
envelope = ApiEnvelope.model_validate(response.json())
|
||||
|
|
@ -177,6 +186,16 @@ class _SyncClientBase:
|
|||
)
|
||||
|
||||
raise_for_error_code(code)
|
||||
except httpx.RequestError as exc:
|
||||
last_error = exc
|
||||
if attempt > self.config.max_retries:
|
||||
raise
|
||||
time.sleep(_backoff_delay(self.config.retry_backoff_seconds, attempt))
|
||||
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
|
||||
raise RuntimeError("Request handling failed unexpectedly")
|
||||
|
||||
|
||||
class SyncBooklookerClient(_SyncClientBase):
|
||||
|
|
@ -221,6 +240,9 @@ class SyncBooklookerClient(_SyncClientBase):
|
|||
encoding: str | None = None,
|
||||
) -> UploadReceipt:
|
||||
path = Path(file_path)
|
||||
if path.stat().st_size > self.config.max_upload_size_bytes:
|
||||
raise ValueError("File exceeds configured Booklooker upload size limit")
|
||||
|
||||
params: dict[str, Any] = {
|
||||
"fileType": file_type,
|
||||
"dataType": data_type,
|
||||
|
|
@ -244,6 +266,23 @@ class SyncBooklookerClient(_SyncClientBase):
|
|||
raw = self._request("GET", "/file_status", params={"filename": filename, "showErrors": int(show_errors)})
|
||||
return _parse_file_status(filename, raw, show_errors)
|
||||
|
||||
def wait_for_file_processing(
|
||||
self,
|
||||
filename: str,
|
||||
*,
|
||||
timeout_seconds: float = 300,
|
||||
poll_interval_seconds: float = 2.0,
|
||||
include_errors: bool = False,
|
||||
) -> FileStatusResult:
|
||||
deadline = time.time() + timeout_seconds
|
||||
last_status = FileStatusResult(filename=filename, state="UNKNOWN")
|
||||
while time.time() < deadline:
|
||||
last_status = self.get_file_status(filename, show_errors=include_errors)
|
||||
if last_status.state in {"UPLOAD_DONE", "UPLOAD_FAILED"} or (include_errors and last_status.errors):
|
||||
return last_status
|
||||
time.sleep(max(0.0, poll_interval_seconds))
|
||||
return last_status
|
||||
|
||||
def get_import_status(self) -> ImportQueueStatus:
|
||||
raw = self._request("GET", "/import_status")
|
||||
try:
|
||||
|
|
@ -357,6 +396,9 @@ class AsyncBooklookerClient:
|
|||
token = await self._get_token()
|
||||
request_params.setdefault("token", token.token)
|
||||
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, self.config.max_retries + 2):
|
||||
try:
|
||||
response = await self._http.request(method, path, params=request_params, headers=headers, content=content)
|
||||
response.raise_for_status()
|
||||
envelope = ApiEnvelope.model_validate(response.json())
|
||||
|
|
@ -381,6 +423,16 @@ class AsyncBooklookerClient:
|
|||
)
|
||||
|
||||
raise_for_error_code(code)
|
||||
except httpx.RequestError as exc:
|
||||
last_error = exc
|
||||
if attempt > self.config.max_retries:
|
||||
raise
|
||||
await asyncio.sleep(_backoff_delay(self.config.retry_backoff_seconds, attempt))
|
||||
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
|
||||
raise RuntimeError("Request handling failed unexpectedly")
|
||||
|
||||
async def get_article_list(
|
||||
self,
|
||||
|
|
@ -423,6 +475,9 @@ class AsyncBooklookerClient:
|
|||
encoding: str | None = None,
|
||||
) -> UploadReceipt:
|
||||
path = Path(file_path)
|
||||
if path.stat().st_size > self.config.max_upload_size_bytes:
|
||||
raise ValueError("File exceeds configured Booklooker upload size limit")
|
||||
|
||||
params: dict[str, Any] = {
|
||||
"fileType": file_type,
|
||||
"dataType": data_type,
|
||||
|
|
@ -446,6 +501,23 @@ class AsyncBooklookerClient:
|
|||
raw = await self._request("GET", "/file_status", params={"filename": filename, "showErrors": int(show_errors)})
|
||||
return _parse_file_status(filename, raw, show_errors)
|
||||
|
||||
async def wait_for_file_processing(
|
||||
self,
|
||||
filename: str,
|
||||
*,
|
||||
timeout_seconds: float = 300,
|
||||
poll_interval_seconds: float = 2.0,
|
||||
include_errors: bool = False,
|
||||
) -> FileStatusResult:
|
||||
deadline = time.time() + timeout_seconds
|
||||
last_status = FileStatusResult(filename=filename, state="UNKNOWN")
|
||||
while time.time() < deadline:
|
||||
last_status = await self.get_file_status(filename, show_errors=include_errors)
|
||||
if last_status.state in {"UPLOAD_DONE", "UPLOAD_FAILED"} or (include_errors and last_status.errors):
|
||||
return last_status
|
||||
await asyncio.sleep(max(0.0, poll_interval_seconds))
|
||||
return last_status
|
||||
|
||||
async def get_import_status(self) -> ImportQueueStatus:
|
||||
raw = await self._request("GET", "/import_status")
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -16,4 +16,7 @@ class BooklookerConfig(BaseModel):
|
|||
user_agent: str = Field(default="booklooker-client/0.1.0")
|
||||
auto_refresh_token: bool = Field(default=True)
|
||||
token_idle_timeout_seconds: int = Field(default=600, ge=60)
|
||||
max_retries: int = Field(default=2, ge=0, le=10)
|
||||
retry_backoff_seconds: float = Field(default=0.5, ge=0)
|
||||
max_upload_size_bytes: int = Field(default=80 * 1024 * 1024, gt=0)
|
||||
openapi_path: Path = Field(default_factory=lambda: Path(__file__).resolve().parents[2] / "openapi.yaml")
|
||||
|
|
|
|||
|
|
@ -1,30 +1,78 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from .models.webhook import MiddlewareEvent, WebhookEvent
|
||||
|
||||
|
||||
class InMemoryIdempotencyStore:
|
||||
def __init__(self) -> None:
|
||||
self._seen: set[str] = set()
|
||||
def __init__(self, ttl_seconds: float = 24 * 60 * 60) -> None:
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self._seen: dict[str, float] = {}
|
||||
|
||||
def _purge_expired(self) -> None:
|
||||
now = time.time()
|
||||
expired = [event_id for event_id, expires_at in self._seen.items() if expires_at <= now]
|
||||
for event_id in expired:
|
||||
self._seen.pop(event_id, None)
|
||||
|
||||
def has_seen(self, event_id: str) -> bool:
|
||||
self._purge_expired()
|
||||
return event_id in self._seen
|
||||
|
||||
def mark_seen(self, event_id: str) -> None:
|
||||
self._seen.add(event_id)
|
||||
expires_at = time.time() + self.ttl_seconds
|
||||
self._seen[event_id] = expires_at
|
||||
|
||||
|
||||
class BooklookerWebhookHelper:
|
||||
"""Utility toolbox for parsing and enriching Booklooker push payloads."""
|
||||
|
||||
def __init__(self, idempotency_store: InMemoryIdempotencyStore | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
idempotency_store: InMemoryIdempotencyStore | None = None,
|
||||
webhook_secret: str | None = None,
|
||||
signature_header_names: tuple[str, ...] = (
|
||||
"x-booklooker-signature",
|
||||
"x-signature",
|
||||
"x-hub-signature-256",
|
||||
),
|
||||
) -> None:
|
||||
self.idempotency_store = idempotency_store or InMemoryIdempotencyStore()
|
||||
self.webhook_secret = webhook_secret
|
||||
self.signature_header_names = tuple(name.lower() for name in signature_header_names)
|
||||
|
||||
def parse_event(self, payload: dict[str, Any]) -> WebhookEvent:
|
||||
return WebhookEvent.model_validate(payload)
|
||||
|
||||
def get_signature_from_headers(self, headers: Mapping[str, Any] | None) -> str | None:
|
||||
if not headers:
|
||||
return None
|
||||
|
||||
lowered = {str(key).lower(): str(value) for key, value in headers.items()}
|
||||
for name in self.signature_header_names:
|
||||
if name in lowered:
|
||||
return lowered[name]
|
||||
return None
|
||||
|
||||
def verify_signature(self, payload: bytes, signature: str | None) -> bool:
|
||||
if self.webhook_secret is None:
|
||||
return True
|
||||
if not signature:
|
||||
return False
|
||||
|
||||
normalized = signature.split("=", 1)[-1].strip().lower()
|
||||
expected = hmac.new(self.webhook_secret.encode("utf-8"), payload, hashlib.sha256).hexdigest().lower()
|
||||
return hmac.compare_digest(normalized, expected)
|
||||
|
||||
def validate_request(self, payload: bytes, headers: Mapping[str, Any] | None = None) -> bool:
|
||||
signature = self.get_signature_from_headers(headers)
|
||||
return self.verify_signature(payload, signature)
|
||||
|
||||
def is_duplicate(self, event: WebhookEvent) -> bool:
|
||||
return self.idempotency_store.has_seen(event.event_id)
|
||||
|
||||
|
|
@ -98,4 +146,4 @@ class BooklookerWebhookHelper:
|
|||
return self.to_middleware_event(event, resource_type=resource_type, enriched_data=enriched)
|
||||
|
||||
def fastapi_receiver_snippet(self, route: str = "/webhooks/booklooker") -> str:
|
||||
return f'''from fastapi import FastAPI, Request\nfrom booklooker_client import BooklookerConfig, SyncBooklookerClient, BooklookerWebhookHelper\n\napp = FastAPI()\nhelper = BooklookerWebhookHelper()\nclient = SyncBooklookerClient(BooklookerConfig(api_key="YOUR_API_KEY"))\n\n@app.post("{route}")\nasync def receive_booklooker_webhook(request: Request):\n payload = await request.json()\n event = helper.enrich_with_client(payload, client)\n return {{"accepted": True, "event": event.model_dump(mode="json")}}\n'''
|
||||
return f'''import os\n\nfrom fastapi import FastAPI, HTTPException, Request\nfrom booklooker_client import BooklookerConfig, SyncBooklookerClient, BooklookerWebhookHelper\n\napp = FastAPI()\nhelper = BooklookerWebhookHelper(webhook_secret=os.environ.get("BOOKLOOKER_WEBHOOK_SECRET"))\nclient = SyncBooklookerClient(BooklookerConfig(api_key=os.environ.get("BOOKLOOKER_API_KEY", "YOUR_API_KEY")))\n\n@app.post("{route}")\nasync def receive_booklooker_webhook(request: Request):\n raw_body = await request.body()\n if not helper.validate_request(raw_body, request.headers):\n raise HTTPException(status_code=401, detail="Invalid webhook signature")\n payload = await request.json()\n event = helper.enrich_with_client(payload, client)\n return {{"accepted": True, "event": event.model_dump(mode="json")}}\n'''
|
||||
|
|
|
|||
50
tests/test_resilience.py
Normal file
50
tests/test_resilience.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import hashlib
|
||||
import hmac
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from booklooker_client import BooklookerConfig, BooklookerWebhookHelper, SyncBooklookerClient
|
||||
|
||||
|
||||
def test_sync_client_retries_transient_timeout() -> None:
|
||||
attempts = {"count": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
attempts["count"] += 1
|
||||
if attempts["count"] == 1:
|
||||
raise httpx.ReadTimeout("temporary timeout", request=request)
|
||||
return httpx.Response(200, json={"status": "OK", "returnValue": "REST_API_TOKEN"})
|
||||
|
||||
config = BooklookerConfig(api_key="demo", max_retries=1, retry_backoff_seconds=0)
|
||||
client = SyncBooklookerClient(config)
|
||||
client._http = httpx.Client(transport=httpx.MockTransport(handler), base_url=config.base_url)
|
||||
|
||||
try:
|
||||
token = client.authenticate()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert token.token == "REST_API_TOKEN"
|
||||
assert attempts["count"] == 2
|
||||
|
||||
|
||||
def test_webhook_signature_verification() -> None:
|
||||
payload = b'{"event_type":"order.created","event_id":"evt-10"}'
|
||||
secret = "top-secret"
|
||||
signature = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
|
||||
|
||||
helper = BooklookerWebhookHelper(webhook_secret=secret)
|
||||
assert helper.verify_signature(payload, signature) is True
|
||||
|
||||
|
||||
def test_import_file_rejects_oversized_input(tmp_path) -> None:
|
||||
file_path = tmp_path / "payload.bin"
|
||||
file_path.write_bytes(b"1234")
|
||||
|
||||
client = SyncBooklookerClient(BooklookerConfig(api_key="demo", max_upload_size_bytes=1))
|
||||
try:
|
||||
with pytest.raises(ValueError):
|
||||
client.import_file(file_path, data_type=0)
|
||||
finally:
|
||||
client.close()
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
from booklooker_client import BooklookerWebhookHelper
|
||||
from booklooker_client import BooklookerWebhookHelper, InMemoryIdempotencyStore
|
||||
from booklooker_client.models.order import OrderBatch, OrderRecord
|
||||
|
||||
|
||||
|
|
@ -39,3 +39,9 @@ def test_duplicate_detection() -> None:
|
|||
assert first.resource_type == "order"
|
||||
assert second.resource_type == "duplicate"
|
||||
assert second.enriched_data == {"duplicate": True}
|
||||
|
||||
|
||||
def test_idempotency_store_ttl_expiry() -> None:
|
||||
store = InMemoryIdempotencyStore(ttl_seconds=0)
|
||||
store.mark_seen("evt-expire")
|
||||
assert store.has_seen("evt-expire") is False
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue