SEP-1820: Hold Celery beat until the HTTP API answers /health - #1347
Open
marcuscruz-percona wants to merge 7 commits into
Open
SEP-1820: Hold Celery beat until the HTTP API answers /health#1347marcuscruz-percona wants to merge 7 commits into
marcuscruz-percona wants to merge 7 commits into
Conversation
Beat's schedule is persisted by the sqlalchemy scheduler, so a restart dispatches anything already overdue about a second in -- before uvicorn.run has opened its listening socket. A periodic task whose first act is to call SEP's own inventory or tasks API failed on connect through no fault of its own. start_celery_beat now polls the local health path until it answers 200 before constructing beat. The worker is left ungated: it idles harmlessly until a task arrives, so delaying it would regress startup for nothing. The gate is best-effort -- a timeout is logged at ERROR and beat starts anyway, because running no periodic task at all until an operator notices is the worse failure. The probe uses http.client so no proxy environment variable can divert it, no redirect can lead it away from the local listener, and no request-level retry can hide the poll count. It never reads the response body: draining one refreshes the socket timeout per chunk, which would let a listener dribbling an endless body hold beat well past its deadline. Each attempt is capped by the time left in the budget for the same reason. A bind-only address is recognised by asking ipaddress whether it is the unspecified address rather than by matching literals, so every spelling of it resolves to loopback. Where ALLOWED_HOSTS restricts the Host header, the probe borrows a configured hostname -- synthesizing one from a wildcard pattern when that is all the allow-list offers -- so TrustedHostMiddleware admits it instead of answering 400 for the whole deadline.
marcuscruz-percona
requested review from
a team,
peter-o-addo and
yyyyyyyan
as code owners
August 13, 2026 16:35
Contributor
There was a problem hiding this comment.
Pull request overview
This PR prevents Celery beat from dispatching overdue periodic tasks before the FastAPI server is actually reachable by adding a local readiness probe that polls GET /health and gating start_celery_beat() on that probe (best-effort, with a logged timeout fallback).
Changes:
- Add
wait_for_api_ready()and supporting helpers/constants inapp/core/health.pyto probe the local HTTP listener safely (no proxy/redirect influence, bounded per-attempt timeout). - Update
app/main.py::start_celery_beat()to configure logging, wait for API readiness, and only then construct beat (while leaving the worker ungated). - Add extensive unit + real-socket tests for the probe and the beat startup ordering, plus a reusable
HealthProbeServerfixture, and a changelog fragment.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
app/core/health.py |
Introduces the readiness probe (wait_for_api_ready) and consolidates the /health route path via HEALTH_PATH. |
app/main.py |
Gates Celery beat startup on the readiness probe (best-effort) and configures logging in the beat process. |
tests/app/test_main.py |
Adds beat readiness gate tests, including end-to-end ordering assertions using a real listener fixture. |
tests/app/core/test_health.py |
Adds unit + socket-level coverage for probe host/host-header resolution, polling behavior, and timeout semantics. |
tests/app/conftest.py |
Adds HealthProbeServer fixture to drive a real loopback /health listener for tests. |
changelog.d/SEP-1820.fixed.md |
Documents the user-visible behavior change in Celery beat startup. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The module patches logging.config.dictConfig, but the attribute only existed because app.main imports the submodule. Bind it here so the tests do not depend on another module's import list.
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||
Contributor
Author
|
@copilot resolve the merge conflicts in this pull request |
Co-authored-by: marcuscruz-percona <272857389+marcuscruz-percona@users.noreply.github.com>
Contributor
Place the http.server import after collections.abc so the stdlib from-import block is alphabetical, which is what ruff's isort rule requires.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
python -m app.main --start-celery, beat's schedule is persisted by thesqlalchemyscheduler, so a restart dispatches anything already overdue about a second in — beforeuvicorn.runhas opened its listening socket. A periodic task whose first act is to call SEP's own inventory or tasks API failed on connect through no fault of its own.start_celery_beatnow polls the local health path until it answers200before constructing beat.ERRORand beat starts anyway, because running no periodic task at all until an operator notices is the worse failure.app/core/health.pybeside the route it dials, soHEALTH_PATHcannot drift from the registered path. It useshttp.clientso no proxy environment variable can divert it, no redirect can lead it away from the local listener, and no request-level retry can hide the poll count. It never reads the response body and caps each attempt by the time left in the budget — draining a response refreshes the socket timeout per chunk, so a listener dribbling an endless body would otherwise hold beat well past its deadline.Two deliberate calls worth a reviewer's eye:
API_READINESS_TIMEOUT60s,API_READINESS_POLL_INTERVAL0.5s,API_READINESS_REQUEST_TIMEOUT2s) are module constants rather than settings fields. Nothing operational is known to need them tuned per deployment; promoting them later is additive.ALLOWED_HOSTSrestricts theHostheader, the probe borrows a configured hostname — synthesizing one from a wildcard pattern when that is all the allow-list offers — soTrustedHostMiddlewareadmits it instead of answering400for the whole deadline. One recorded limitation: a deployment binding an IPv6 literal it also lists verbatim still gets400, because Starlette compareshost.split(":")[0], which no IPv6 literal can satisfy. That case is pinned as a test row against Starlette's own middleware rather than papered over.Tested
python -m app.main --start-celerywith a periodic task left overdue in the DB: beat waits, logsWaiting up to 60.0s for the HTTP API at 127.0.0.1:8000 to answer /health, thenis ready after Ns, and the task's first run reaches the API instead of failing on connect/healthanswers503throughout: the wait ends at the deadline, logs theERRORwith the last outcome, and beat still startsCtrl-Cduring the wait: the beat process exits quietly with no tracebackpython -m app.main(no--start-celery) is unchanged — no wait, no new log linesFASTAPI_ENV=production_docker,UVICORN_HOST=0.0.0.0) still reaches beat, confirming the bind-address translation dials loopbackChecklist
make test)make run-pre-commit)make makemigrations)changelog.d/if the change is user-facing (make changelog-add), or confirmed N/A (internal-only change, or a same-release-cycle fix for an unreleased sibling ticket)