Sentry SDK/PostHog SSL decryption failed or bad record mac with Gunicorn preload uvicorn workers
sentry-sdk 2.58.0, posthog-python 3.x, gunicorn 25.x with --preload and uvicorn.workers.UvicornWorker. Calling sentry_sdk.init() or posthog.Posthog() during app construction (before gunicorn forks workers) causes all workers to inherit the master's TLS connection pool and background consumer/flush threads.
The TLS pool sharing produces SSLError(1, '[SSL: DECRYPTION_FAILED_OR_BAD_RECORD_MAC]') on /api/N/envelope/ when multiple workers write to the same SSL socket concurrently. Observed: ~58 SSL errors per 24h out of ~213 envelope submissions (~27% failure rate), spread evenly 1-5/hour. The error is retried by urllib3 but silently dropped when retries exhaust, making every Sentry verdict provisional.
PostHog's consumer thread dies on fork (threads don't survive fork()), so events enqueued in workers are pushed to a copy-on-write queue that no consumer reads. Events are silently lost with no error signal.
The error signature (DECRYPTION_FAILED_OR_BAD_RECORD_MAC on an HTTPS endpoint) misleads toward network/certificate problems. The actual cause is process-level: two processes sharing one TLS session interleave their encrypted records, corrupting the MAC. Diagnosable by checking whether sentry_sdk.init() runs before os.fork() in the process tree.
Initialize sentry-sdk and posthog-python per-worker, after fork, not at module/app scope.
For ASGI apps (FastAPI/Starlette with uvicorn workers), move sentry_sdk.init() and posthog.Posthog() into the ASGI lifespan startup handler. The lifespan runs per-worker after fork, so each worker gets its own TLS pool and consumer thread:
@asynccontextmanager
async def lifespan(app):
# Per-worker init: transport is not fork-safe
sentry_sdk.init(dsn=DSN, ...)
posthog_client = posthog.Posthog(api_key, host=host)
yieldFor WSGI apps or when a gunicorn config file is preferred, use the post_fork hook:
# gunicorn_conf.py
def post_fork(server, worker):
import sentry_sdk
sentry_sdk.init(dsn=DSN, ...)Critical: do NOT call sentry_sdk.init() at module scope or in a function that runs during --preload import. If using a lazy-init guard pattern (if _client is not None: return), ensure the guard variable is None at fork time so each worker re-initializes.
References:
- PostHog/posthog-python#290: identical root cause for PostHog's consumer thread
- getsentry/sentry-python#3781: sentry-sdk socket sharing with gunicorn
- gunicorn/gunicorn#2894: fork behavior under --preload