Skip to content

(feat) restore rate oracle config endpoints, scoped to bots - #235

Merged
david-hummingbot merged 13 commits into
mainfrom
feat/bot-rate-oracle-config
Sep 22, 2026
Merged

david-hummingbot merged 13 commits into
mainfrom
feat/bot-rate-oracle-config

Conversation

@cardosofede

@cardosofede cardosofede commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

@david-hummingbot could you review this one?

Why

f067c37 replaced the API's RateOracle with the in-house ticker pool and deleted routers/rate_oracle.py, which also removed the only way to change rate_oracle_source through the API. The API itself no longer needs it, but deployed bots still run hummingbot's RateOracle from the conf_client.yml copied out of their credentials profile at deploy time. Without these endpoints every bot always starts with the template's source (gate_io).

What

Config-only endpoints under bot orchestration, so it's explicit they configure bots, not the API's pricing:

  • GET /bot-orchestration/rate-oracle/sources: valid source names
  • GET /bot-orchestration/rate-oracle/config?account_name=master_account: current source and global token
  • PUT /bot-orchestration/rate-oracle/config?account_name=...: validates the source and saves it to bots/credentials/<account>/conf_client.yml

It works per account (credentials profile), since that is what each bot copies. Setting global_token_name on master_account also switches the API's own quote token live (new MarketDataService.quote_token setter).

Notes

  • Running bots keep their copied config; changes apply to the next deploy.
  • New accounts copy master_account's conf_client.yml only once, when created, so existing accounts must be updated individually.
  • Saving rewrites the YAML, so comments in conf_client.yml are lost (same as the removed endpoint).

Also: GET /system/info

Why: a dashboard's Settings → Server panel can say which server it is talking to, but not what that server runs. Which hummingbot-api version, which hummingbot library behind it, and — the question asked after every incident — whether the image is the published one or something pinned on that box. Today that is answered by ssh-ing in and reading docker inspect.

What it returns (added to the existing routers/system.py, same auth as the rest):

  • api_version — the API's VERSION
  • hummingbot_version — importlib.metadata.version("hummingbot"), null if not installed
  • docker_available — whether the daemon could be reached
  • container — the API's own container through the already-mounted docker.sock: id, name, image, image_id, digest (from RepoDigests), compose_project, compose_working_dir, compose_config_files
  • pinned / pinned_reason / override_file — pinned is true when the image has no registry digest (built locally) or when the compose config_files label lists an override file, which is then named. docker-compose.tailscale.yml does not count: it repoints networking, not the image.

It identifies itself the way a process does from inside Docker — the hostname is the short container id — falling back to a scan for the com.docker.compose.service label when the deployment overrides hostname: or runs the API from source.

Read-only, and it never raises. Without docker.sock, with an unreachable daemon, or when the process cannot place itself, it answers docker_available: false with a null container and a null (not false) pinned — unknown is not "unpinned" — and still reports both versions.

VERSION moves from main.py into a new one-line version.py so a router can read it without importing main.py back (main imports every router). main.py re-exports it, and the Docker buildx workflow's grep now reads version.py.

Tests

pytest test/test_bot_rate_oracle_config.py: 8 passed (read, persist without clobbering other keys, per-account isolation, quote token sync, invalid source → 400, unknown/unsafe account → 404/400).
pytest test/test_system_info.py: 8 passed with the docker client mocked (versions + digest + compose labels, locally built image → pinned, override file → pinned and named, tailscale overlay → not pinned, hostname miss → compose-service-label fallback, unreachable daemon / unidentifiable container / no docker client → docker_available: false with the versions still reported).

🤖 Generated with Claude Code

Replacing RateOracle with the in-house ticker pool (f067c37) also deleted the
endpoints that set rate_oracle_source in conf_client.yml. The API no longer uses
it, but every deployed bot still runs hummingbot's RateOracle from the
conf_client.yml copied out of its credentials profile, so bots were stuck on the
template's source.

Adds GET /bot-orchestration/rate-oracle/sources and GET/PUT
/bot-orchestration/rate-oracle/config, per account (account_name, default
master_account). Updating master_account's global token also switches the
API's own quote token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

The PR is not yet safe to merge because startup can destroy the latest self-upgrade record and expose stale upgrade status when multiple exited helpers exist for the same deployment.

Findings

  1. P1 Stale helper replaces status ▶
  2. P2 Blank symbols break reads ▶

Summary

This PR restores account-scoped bot rate-oracle configuration, exposes system and deployment information, and adds a Docker-based self-upgrade workflow.

  • Adds authenticated endpoints for reading and updating bot rate-oracle, global-token, and rate-limit settings.
  • Adds system version, container provenance, and image-pinning information.
  • Adds guarded self-upgrade preflight, execution, and status endpoints.
  • Moves the API version into a standalone module and updates Docker packaging and release automation.
  • The latest changes harden malformed YAML reads and scope helper collection by Compose project and active run, but boot-time collection can still consume multiple stale same-project records in an arbitrary order.
Diagram
sequenceDiagram
    participant Dashboard
    participant API as hummingbot-api
    participant Docker
    participant Helper as docker:27-cli
    participant NewAPI as Recreated API

    Dashboard->>API: GET /system/upgrade/preflight
    API->>Docker: Inspect own container and registry state
    Docker-->>API: Compose metadata and image digests
    API-->>Dashboard: Upgrade eligibility and impact

    Dashboard->>API: POST /system/upgrade
    API->>Docker: Pull API and helper images
    API->>Helper: Start labeled helper container
    Helper->>Docker: docker compose up -d --no-deps hummingbot-api
    Docker->>NewAPI: Start replacement container
    NewAPI->>Docker: Collect exited helper result
    Dashboard->>NewAPI: GET /system/upgrade/status
    NewAPI-->>Dashboard: Upgrade outcome
Loading

Reviews (7) · Last reviewed commit: "fix(bot-rate-oracle): tolerate a hand-ed..."

Comment thread routers/bot_rate_oracle.py Outdated
Comment thread routers/bot_rate_oracle.py Outdated
…ly after persisting

A blank global_token_name was persisted and installed as the API quote token,
so every cross-rate lookup resolved to nothing (and kept doing so after a
restart). And the live token was switched before conf_client.yml was written,
so a failed write left the API on the new token while bots kept the old one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…GET /system/info

The Settings -> Server panel of a dashboard can say which server it is talking to but
not what that server runs: the hummingbot-api version, the hummingbot library version
behind it, and whether the image is the published one or something pinned on the box.
That last one is the question asked after every incident ("is this host actually on the
build we shipped?"), and today it is answered by ssh-ing in and reading `docker inspect`.

/system/info answers it over the API. It reports the API version, the hummingbot
version from importlib.metadata, and the API's own container as seen through the
already-mounted docker.sock: id, name, image reference, image id, RepoDigests digest,
and the compose project, working_dir and config_files labels. From those it derives
`pinned`: an image with no registry digest was built locally, and a config_files entry
whose name contains "override" means compose was brought up with a file that can
repoint `image:` (the tailscale overlay does not count -- it changes networking, not
the image). `pinned_reason` says which, and `override_file` names the file.

The route is read-only and never raises. Without docker.sock, with an unreachable
daemon, or when this process cannot place itself, it answers `docker_available: false`
with a null container and a null (not false) `pinned` -- unknown is not "unpinned" --
and still reports both versions, which is the part that works from source too.

The container is found the way a process finds itself from inside Docker: the hostname
is the short container id, with a fallback scan for the com.docker.compose.service
label for deployments that override `hostname:` or run the API from source.

VERSION moves from main.py to a new version.py so the router can read it -- main.py
imports every router, so importing it back would be circular. main.py re-exports it,
and the Docker buildx workflow's grep now reads version.py.
Comment thread routers/system.py Outdated
Comment thread routers/system.py Outdated
31e671d moved VERSION out of main.py so routers can read it without importing main,
but the Dockerfile copies source files one by one and version.py was not on the list,
so the built image would fail at startup on `from version import VERSION`. The build
itself stays green -- nothing imports the app at build time -- which is why the miss is
worth a line of its own here.
A dashboard's server panel can now say what a server runs, but not how it is tuned.
The MARKET_DATA_* knobs are the ones an operator actually asks about after a slow
ticker or a dropped subscription -- ticker_update_interval, ticker_max_age, the
/ws/executors interval floor -- and today reading them means ssh-ing in and opening
the .env.

/system/info reports them alongside the versions, as this process resolved them,
which is the honest answer: the file on disk may have been edited since the API
started. They are read-only by construction and stay that way -- env_file is not
mounted into the container, so the API cannot rewrite its own .env, and pretending
otherwise would promise a change that silently disappears.

Safe to report: MarketDataSettings holds intervals and timeouts and nothing else.
A test pins that -- every value numeric, no key that reads like a credential -- so a
field added later that does not belong in this payload fails here rather than
leaking. Another pins that the tunables are still reported when the Docker daemon is
unreachable, which is the same degrade path the versions already take.

The mock container id in the test file gains a `noqa: mock` tag: it is 64 hex
characters, which the wallet-private-key hook cannot tell from a key.
… client defaults

The rate-oracle endpoints already read and write the slice of a credentials profile's
conf_client.yml that deployed bots inherit. rate_limits_share_pct -- the share of each
exchange's API rate limit one bot instance may spend -- is the other knob operators
actually tune per profile, and it is the one they reach for when several bots share an
account and start tripping the exchange's limiter. It was the last reason to open the
file by hand.

It goes here rather than behind an endpoint of its own on purpose. This router is the
only writer of that file, and a second one would have to repeat the source validation
and would silently skip the live-quote-token switch at the end of the PUT -- two writers
of one file disagreeing about the side effects is the bug, not the duplication. The set
written stays closed and small, so no caller can reach the mqtt_bridge or gateway
credentials sharing the file.

The bounds are hummingbot's own: gt=0, le=100, copied from ClientConfigMap so a value
the bot would reject at startup cannot be persisted here -- a bad write breaks the next
deploy, not the request that made it, which is the expensive way to find out. Reading
degrades instead: the key is absent from every profile created before this commit, and a
hand-edited file can hold anything, so an unreadable value answers with hummingbot's
default rather than failing a GET that is only asking what bots will run with.
@david-hummingbot

Copy link
Copy Markdown
Contributor

Tested this branch deployed rather than just unit-tested — built the image and ran the compose stack, and separately ran uvicorn main:app from source, both against a live postgres/emqx stack. Also ran it end to end with hummingbot/hummingbot-api-client#27 and against condor main.

The feature works. The premise is demonstrated: I set rate_oracle_source: hyperliquid through the API, deployed a bot, and the instance's conf_client.yml came out with hyperliquid. Both that file and the rewritten master_account file validate through hummingbot's real ClientConfigMap. 40 concurrent PUTs left the file intact with all 24 top-level keys. Path traversal is rejected on both GET and PUT with nothing written. The master_account → live quote_token coupling works, and non-master accounts correctly don't move it.

Three issues below, none of which the existing tests catch — all three only show up against a real deployment.


Issue 1 — pinned never detects a locally built image on containerd-backed Docker

Description

_pinning() infers "built on this box" from the absence of a registry digest:

if not container.get("digest"):
    reasons.append("image was built locally (no registry digest)")

With Docker's containerd image store (io.containerd.snapshotter.v1, the default on current Docker Desktop and increasingly on Linux), a locally built image does get a RepoDigests entry. So the check never fires and a hand-built image reports pinned: false — the exact case the endpoint exists to detect, answered wrongly and with confidence.

test_locally_built_image_is_pinned mocks RepoDigests: [], which is the legacy graphdriver behavior. The mock encodes an assumption that no longer holds, so the test passes while production doesn't.

The distinguishing signal is available: for a local build the digest's sha equals image_id, and its repo (hbapi) isn't a registry path. Comparing digest against image_id, or requiring the digest repo to look like a published repo, would restore the check.

Steps to reproduce

# on a daemon using the containerd image store (docker info | grep -i snapshotter)
docker build -t hbapi:pr235 .
# point docker-compose.yml at hbapi:pr235, then:
docker compose up -d --force-recreate hummingbot-api
curl -s -u admin:admin http://127.0.0.1:8000/system/info | jq '{image:.container.image, digest:.container.digest, image_id:.container.image_id, pinned, pinned_reason}'

Observed:

{
  "image": "hbapi:pr235",
  "digest": "hbapi@sha256:f84fd1ab6802c4434a8ea709c63a7a99c30b4aaa80e828fc508d1ebb2e65f806",
  "image_id": "sha256:f84fd1ab6802c4434a8ea709c63a7a99c30b4aaa80e828fc508d1ebb2e65f806",
  "pinned": false,
  "pinned_reason": null
}

Expected pinned: true with "image was built locally". Note digest and image_id carry the same sha.


Issue 2 — blank global_token_name returns 500, not 422

Description

The guard added in 17e62b2 rejects the value correctly (nothing is written), but the response is 500 Internal Server Error rather than a validation error.

Root cause is the existing handler at main.py:431:

return JSONResponse(status_code=422, content={"detail": exc.errors()})

For a validator that raises a bare ValueError, exc.errors() includes ctx: {"error": ValueError(...)}. That object isn't JSON-serializable, so rendering the 422 throws TypeError: Object of type ValueError is not JSON serializable and FastAPI falls back to a 500.

This handler is pre-existing on main, not introduced here — but this PR's validator is the path that actually reaches it. I checked the other ValueError validators in models/trading.py; they're guarded by Literal types that reject first, so they never hit it.

test_blank_global_token_name_rejected mounts the router on a bare FastAPI() with no custom exception handler, so it sees FastAPI's default 422 and passes. It's only observable against the real app.

Reproduces identically on both deployment modes (docker: 500, source: 500). Fix is likely jsonable_encoder(exc.errors()) in main.py, so it may be worth agreeing whether it belongs in this PR.

Steps to reproduce

curl -s -o /dev/null -w '%{http_code}\n' -u admin:admin \
  -X PUT -H 'Content-Type: application/json' \
  -d '{"global_token":{"global_token_name":"   "}}' \
  http://127.0.0.1:8000/bot-orchestration/rate-oracle/config

Observed: 500. Expected: 422. Server log shows TypeError: Object of type ValueError is not JSON serializable raised from main.py:432.


Issue 3 — _own_container can report a different container as its own

Description

When the hostname lookup misses, _own_container falls back to the compose-service label and takes matches[0]:

matches = client.containers.list(filters={"label": f"com.docker.compose.service={COMPOSE_SERVICE}"})
return matches[0] if matches else None

With more than one candidate this returns an arbitrary container, and every container.* field then describes a different deployment. Worse, _pinning() runs on those foreign labels, so the endpoint can report a pinned verdict about this server that was derived entirely from another container.

Two ways in, both reproduced:

  1. Docker, when the deployment sets hostname: — a case the docstring explicitly supports.
  2. Running from source, where the hostname is never a container id. Here the fallback found the containerized API and reported its image, digest and compose_working_dir as its own. (With no container present it correctly returns container: null / pinned: null, so only the multi-candidate path is affected.)

It's also order-dependent: containers.list() is newest-first, so matches[0] is the most recently created candidate. On my first attempt it happened to pick the right one; recreating the decoy afterwards flipped it. On a host running more than one stack, the answer changes whenever any stack is redeployed.

Suggestion: when the fallback matches more than one container, return None rather than guessing — container: null is a better answer than a confident wrong one, and is already handled gracefully downstream.

Steps to reproduce

# 1. make the hostname lookup miss
#    add `hostname: apibox` under the hummingbot-api service in docker-compose.yml
docker compose up -d --force-recreate hummingbot-api

# 2. add a second container carrying the same compose-service label
docker run -d --name decoy-api \
  --label com.docker.compose.service=hummingbot-api \
  --label com.docker.compose.project=some-other-stack \
  --label com.docker.compose.project.working_dir=/opt/other-deployment \
  --label com.docker.compose.project.config_files=/opt/other-deployment/docker-compose.override.yml \
  alpine:latest sleep 3600

# 3. ask the API what it is
curl -s -u admin:admin http://127.0.0.1:8000/system/info | jq '{name:.container.name, image:.container.image, project:.container.compose_project, pinned, override_file}'

Observed — the API reports the decoy, including an alpine:latest image and a pinned: true verdict manufactured from the decoy's labels:

{
  "name": "decoy-api",
  "image": "alpine:latest",
  "project": "some-other-stack",
  "pinned": true,
  "override_file": "docker-compose.override.yml"
}

Cleanup: docker rm -f decoy-api and drop the hostname: line.


Also verified (no action needed)

  • docker-compose.override.yml → pinned: true and correctly named; docker-compose.tailscale.yml → correctly not pinned.
  • version.py is in the image and imports — d8e3f1f fixes a real boot failure. CI grep against version.py yields 1.0.1.
  • Without docker.sock / with the daemon unreachable / when the container can't be placed: 200 with docker_available: false, null container, pinned: null. Never raises.
  • (feat) client methods for bot rate oracle config hummingbot-api-client#27 against this branch: 8/8 live calls pass, and its vendored spec matches this server's /openapi.json exactly for all three new paths.
  • condor main with the client genuinely pinned+locked to 1.5.10: 5732 passed / 2 skipped, identical to baseline, and its dashboard drives this API with no errors.

One note on the YAML rewrite: it's lossless for data (all 24 keys preserved, including usd_equivalent_tokens which the API's own model doesn't model), just not for comments — the stock template goes 4.3 KB → 2.4 KB on first write. Already called out in the PR body; might be worth a release note since that file is user-editable.

🤖 Generated with Claude Code

cardosofede and others added 3 commits September 18, 2026 07:39
…d image

Upgrading a hummingbot-api server means ssh-ing to the host and running `docker compose
pull && up -d`. Condor's updater only knows how to do that in a local checkout, so every
remote server is a manual job and servers drift -- which is how a box ends up months
behind with nobody able to say so. Three routes, off the same /system prefix that already
reports what a server runs:

  GET  /system/upgrade/preflight   can this server upgrade itself, and what would it cost
  POST /system/upgrade             pull the image and hand the recreate to a helper
  GET  /system/upgrade/status      follow the run, including across the restart it causes

A process cannot recreate its own container: `compose up` stops the old one, killing the
process running compose halfway through, and the image has no compose plugin anyway. So
the API pulls in-process -- what it already does for every other image -- and starts a
one-shot docker:cli container with the socket and the compose working dir bound at the
same host path, running the `docker compose -p <project> -f <files...> up -d --no-deps
hummingbot-api` the operator would have typed. That helper outlives the API it replaces,
and is also the run record: its exit code and log tail are what the *new* container reads
on boot, so there is no state file and no table. The working dir is bound at the identical
path because that is what makes ./bots, .env and the override paths inside those compose
files resolve to the same things they resolved to when the stack was brought up.

Everything here defaults to refusing, because the failure mode is a trading API server
that does not come back. preflight starts blocked and clears that in exactly one place,
once every fact it needs has been established; a fact it could not establish is treated
the same as a fact that says no. An unreachable daemon, a container it cannot place, a
stack with no compose labels, a registry digest it could not read, an executor count it
could not take, a helper listing the daemon refused -- all of them are can_upgrade false
with a reason, never a 500 and never an optimistic default. start re-runs the whole
preflight rather than trusting the caller's earlier read, because between the dialog
opening and the button being pressed an executor can start or another operator can begin
a run, and it claims the run under the same lock it checked, so two callers cannot both
pass and race two `compose up`s at one container.

A pinned deployment is refused outright and named: an image built on the box, or a compose
override file that can repoint `image:`. Removing an override or swapping a hand-built tag
is a decision about the operator's own host -- moneymaker's pr226 pin is exactly this --
and silently rolling such a server onto the published build is worse than not upgrading.
Those stay an SSH job, and the reason says which file.

The consent is explicit and withheld by default: a restart closes every RUNNING executor
as SYSTEM_CLEANUP and they are not restored, so acknowledge_executor_loss defaults to
False and a body-less POST is refused with a 409 rather than served. Bot containers are
separate and keep running, which preflight reports so the operator can see it.

Both images are pulled before anything is stopped, the helper's included -- leaving that
one to containers.run's implicit pull would put the failure after the recreate was already
committed. A pull that fails leaves the server untouched and running, and reports the
image that failed rather than the daemon's own string, which carries the socket URL and
the negotiated API version.

utils/compose.py is the three container/pinning helpers lifted out of routers/system.py
verbatim, because the panel's "is this pinned" and this module's "refuse if pinned" have
to be the same answer. A panel saying not pinned over a path that refuses is a confusing
afternoon; a panel saying not pinned over a path that *proceeds* on a hand-built image is
a trading API server replaced by a build nobody asked for.

Out of scope and stated as such: switching tags or images, editing override files,
rebuilding from source, upgrading postgres/emqx/gateway, and rollback.
… not 500

The app's RequestValidationError handler passed exc.errors() straight to
JSONResponse; a validator raising ValueError puts the exception object in
the error ctx, which is not JSON-serializable, so a blank global_token_name
came back as a 500. Encode with jsonable_encoder, and move the handler to
utils/validation_errors.py so the rate-oracle tests mount the real one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ublished image

- own_container: also find itself by the container id in /proc/self/mountinfo
  (survives a custom hostname:); use the compose-service label fallback only
  from inside a container and only when exactly one container matches.
- describe_container: only a digest in hummingbot/hummingbot-api counts, since
  the containerd image store gives local builds a RepoDigest of their own.
- pinning: a tag other than latest, or a digest-pinned reference, is pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cardosofede

Copy link
Copy Markdown
Contributor Author

Thanks for testing this deployed. All three are fixed:

Issue 1: pinned missed local builds on the containerd store. Fixed in 238ecb7. I didn't compare the digest to image_id: on the containerd store, a pulled image's id also equals its repo digest, so pulled images would read as local too. Instead, only a digest in hummingbot/hummingbot-api counts (with or without a docker.io/ prefix), so hbapi@sha256:… → digest: null, pinned: true. Greptile's related point is covered too: a version tag like :1.0.1 or a digest-pinned reference in the main compose file now reads as pinned. Still undetectable, and noted in the docstring: an image built locally as hummingbot/hummingbot-api:latest on containerd. Nothing on the machine tells it apart from a pulled one.

Issue 2: blank global_token_name returned 500. Fixed in 7e27fae with jsonable_encoder(exc.errors()). I moved the handler to utils/validation_errors.py, and the rate-oracle tests now mount it, so test_blank_global_token_name_rejected goes through the real app's handler. With the old handler it fails with the same TypeError.

Issue 3: _own_container could claim another container. Fixed in 238ecb7:

  • It also finds itself by the container id in /proc/self/mountinfo. Docker bind-mounts /var/lib/docker/containers/<id>/hostname into every container, so a custom hostname: still resolves correctly.
  • The compose-service label fallback runs only from inside a container. From source it returns container: null, even when a single containerised API is on the host (before, that case also misreported).
  • The label fallback returns a container only when exactly one matches, otherwise null. This also protects /system/upgrade, which shares the lookup.

New tests reproduce your cases: the containerd hbapi@sha256 build, the decoy, running from source, and the overridden hostname. Full suite: 899 passed.

🤖 Generated with Claude Code

Comment thread services/self_upgrade.py
@david-hummingbot

Copy link
Copy Markdown
Contributor

Retested at 238ecb7: the three issues from my earlier comment are fixed. Three new ones.

1. A failed upgrade leaves the API permanently stuck (confirms Greptile's P1)

If the helper exits non-zero without replacing the API, start() has already returned after setting phase="recreating", and nothing watches the helper. collect_on_boot() is the only collector and only runs at startup — which never happens, because the API was not replaced. The run stays recreating with exit_code: null forever, and every later upgrade is refused because the exited helper is still on the box.

The refusal text makes it worse: "Wait for it to finish; it is cleared when this API next starts." It has finished (Exited (1)), and the API has no reason to restart. Recovery needs docker restart hummingbot-api or a manual docker rm of the helper — SSH, which is what this endpoint exists to avoid.

Steps to reproduce

# with a server where preflight reports can_upgrade: true
# 1. break compose AFTER the container labels are stamped (preflight reads labels, not the file)
cat >> docker-compose.yml <<'EOF'

  bogus-service:
    image: this/image/does-not-exist:nope
    depends_on:
      - nonexistent-service
EOF

# 2. preflight still allows it
curl -s -u admin:admin localhost:8000/system/upgrade/preflight | jq '.can_upgrade'   # true

# 3. start the upgrade; the helper fails and the API is never replaced
curl -s -u admin:admin -X POST localhost:8000/system/upgrade
docker ps -a --filter label=hummingbot-api.self-upgrade --format '{{.Names}} {{.Status}}'

# 4. the stuck state
curl -s -u admin:admin localhost:8000/system/upgrade/status   | jq '{phase, exit_code, log_tail}'
curl -s -u admin:admin localhost:8000/system/upgrade/preflight | jq '.blocked_reason'
curl -s -o /dev/null -w '%{http_code}\n' -u admin:admin -X POST localhost:8000/system/upgrade

Observed:

helper                ecstatic_lederberg  Exited (1)
container id          unchanged
status                {"phase":"recreating","exit_code":null,"log_tail":[]}
blocked_reason        "An upgrade helper container is already present (ecstatic_lederberg).
                       Wait for it to finish; it is cleared when this API next starts."
POST /system/upgrade  409

docker restart hummingbot-api then records it correctly (phase: failed, exit_code: 1, real error in log_tail) and clears the helper — so the collection logic is right, it just only runs at boot. Collecting an exited helper in preflight() instead of blocking on it would fix the stuck status and the false refusal together.

Note when reproducing: the refusal may first cite pinning rather than the helper, because the upgrade's pull moves the latest tag off a locally built image. Re-tag to unmask the helper check.

2. hummingbot/hummingbot-api-client#27 no longer covers this PR

That PR is still at fcb927a and predates 549f4f5 and ea7f5a0. It is backward compatible (345 unit tests, 8/8 live calls against this branch), but cannot reach:

  • /system/upgrade, /system/upgrade/preflight, /system/upgrade/status — no client methods, and all three missing from the vendored spec
  • rate_limits_share_pct — update_rate_oracle_config() has no such parameter

Steps to reproduce

# against a server running this branch
python - <<'PY'
import json, urllib.request, base64
vend = json.load(open("hummingbot-api-openapi.json"))
req = urllib.request.Request("http://127.0.0.1:8000/openapi.json")
req.add_header("Authorization", "Basic " + base64.b64encode(b"admin:admin").decode())
live = json.load(urllib.request.urlopen(req))
print(sorted(set(live["paths"]) - set(vend["paths"])))
PY
['/performance/history', '/performance/latest',
 '/system/upgrade', '/system/upgrade/preflight', '/system/upgrade/status']

Live PUT body accepts ['global_token', 'rate_limits_share_pct', 'rate_oracle_source']; the vendored spec has the first and third. Since the two PRs are release-coupled, #27 needs a follow-up commit.

3. Blank global_token_symbol is accepted

global_token_name rejects blanks; its sibling in the same model does not, and the blank is written to conf_client.yml.

Steps to reproduce

curl -s -u admin:admin -X PUT -H 'Content-Type: application/json' \
  -d '{"global_token":{"global_token_symbol":"   "}}' \
  localhost:8000/bot-orchestration/rate-oracle/config

grep -A3 global_token bots/credentials/master_account/conf_client.yml

Observed: 200, "message":"global_token_symbol updated to ", and global_token_symbol: ' ' on disk.

🤖 Generated with Claude Code

cardosofede and others added 2 commits September 18, 2026 16:55
…start

A helper that exited without replacing the API was only collected by
collect_on_boot(), which never runs because the API was not replaced: the run
stayed 'recreating' forever and every later preflight refused on the exited
helper. preflight() and status() now collect exited helpers too; collected
from a live process the run is 'failed' whatever the exit code, since this
process is the container the helper was meant to replace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ling

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cardosofede

Copy link
Copy Markdown
Contributor Author

Thanks for the retest. Status of the three:

1. Failed upgrade stuck at recreating. Fixed in ab29ce9, following your suggestion:

  • preflight() now collects exited helpers first, before any other check can return. So a pinning refusal can't hide the stuck helper anymore (the masking you noted).
  • status() also collects when the run it started is still recreating, so polling status alone gets it unstuck.
  • Collected from a live process, the run is failed with the helper's exit code and log tail, whatever the exit code: we're still running, so the recreate didn't happen. The helper is removed, and the next preflight can proceed.
  • Only a helper that is still running blocks now, and the refusal text says "still running" instead of promising a restart that won't happen.

2. hummingbot-api-client#27 is behind. The follow-up commits (64bc3f8 rate_limits_share_pct, a0c397f the three upgrade calls plus the regenerated spec) exist locally and will be pushed to #27 next. The /performance/* paths in your diff come from main, not this PR; the regenerated spec includes them too.

3. Blank global_token_symbol accepted. Fixed in d5bbe50: the same strip-and-reject validator now covers both fields, so the request gets a 422 and nothing is written. Covered by test_blank_global_token_symbol_rejected.

Suite: 901 passed. The 3 failures are the TestGatewayIntegration tests, which need a live Gateway and are unrelated to these changes.

🤖 Generated with Claude Code

Comment thread services/self_upgrade.py
Comment thread models/bot_rate_oracle.py
Comment on lines +27 to +36
@field_validator("global_token_name", "global_token_symbol")
@classmethod
def _reject_blank(cls, v: Optional[str], info) -> Optional[str]:
# A blank quote token would make every cross-rate lookup resolve to nothing, and a
# blank symbol renders every value in the bot's status without a unit. Omit the
# field to leave it unchanged.
if v is not None:
v = v.strip()
if not v:
raise ValueError(f"{info.field_name} must not be blank")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Blank symbols break reads

Extending this validator to global_token_symbol makes the read endpoint fail validation when an existing or manually edited conf_client.yml contains an empty symbol. _to_config passes that persisted value directly into this model, and the GET route does not handle the resulting validation error. This makes the account’s configuration endpoint return a server error instead of exposing or normalizing the configuration.

@david-hummingbot

Copy link
Copy Markdown
Contributor

Retested at d5bbe50: the stuck-upgrade and blank-symbol issues from my last comment are fixed. Full suite 898 passed, routes 32/32, auth correct on all seven new routes, and account-name path safety holds on both methods. Two issues below.

1. A foreign upgrade helper is adopted as this API's own run

Confirms Greptile's P1, with two consequences beyond it.

_collect_finished_helpers assigns self._run = {...} for every exited helper it finds, without matching the run id against the active run. HELPER_LABEL is a bare hummingbot-api.self-upgrade with no compose-project scoping, and the lookup is client.containers.list(all=True, filters={"label": HELPER_LABEL}) — so every hummingbot-api on a host sees every other one's helpers.

Beyond the case Greptile described:

  • It does not need a run in flight. An idle API, which has never started an upgrade, adopts a foreign helper's record as its own.
  • It then removes that helper. The deployment that actually ran the upgrade can never collect its own result, so it stays at recreating indefinitely — the failure mode ab29ce9 just fixed, re-created for the other stack.

Steps to reproduce

# API idle, no upgrade ever started on it
curl -s -u admin:admin localhost:8000/system/upgrade/status | jq '{run_id, phase}'
# {"run_id": null, "phase": "idle"}

# plant an exited helper as another stack's upgrade would leave behind
docker run -d --name foreign-helper \
  --label hummingbot-api.self-upgrade=FOREIGN_RUN_9999 \
  --label hummingbot-api.self-upgrade.previous-digest=hummingbot/hummingbot-api@sha256:deadbeef \
  alpine:latest sh -c 'echo "some other stack failed"; exit 7'

curl -s -o /dev/null -u admin:admin localhost:8000/system/upgrade/preflight   # collects
curl -s -u admin:admin localhost:8000/system/upgrade/status | jq
docker ps -a --filter label=hummingbot-api.self-upgrade

Observed:

{
  "run_id": "FOREIGN_RUN_9999",
  "phase": "failed",
  "exit_code": 7,
  "previous_digest": "hummingbot/hummingbot-api@sha256:deadbeef",
  "log_tail": ["some other stack failed"],
  "detail": "The recreate did not replace this API (exit code 7); it is still running the previous image..."
}

and the foreign helper is gone from docker ps -a.

Matching the run id before adopting a record, and scoping the helper lookup to this container's com.docker.compose.project, would address both halves.

2. A hand-edited conf_client.yml makes the endpoint 500

Greptile's P2 (blank global_token_symbol) is one case of a broader pattern: _to_config assumes every key holds the shape it expects, and _read_conf_client catches only FileNotFoundError, so a yaml.YAMLError reaches the route. Most of these also fail the PUT, so the account cannot be repaired through the API.

persisted state GET PUT (source only)
global_token_symbol: '' 500 500
global_token is a string 500 500
global_token is a list 500 500
rate_oracle_source is a string 500 500
rate_oracle_source.name: null 500 200
global_token_symbol is a number 500 500
file is invalid YAML 500 500
file is a YAML list 500 500
global_token_name: '' 200 200
rate_limits_share_pct: 150 or 'abc' 200 200

The rows that pass are the defensively written ones — global_token_name=... or "USDT", and _share_pct, whose docstring already states the principle: "a hand-edited file can hold anything, so an unreadable value reads as the default rather than failing the whole GET." The rest of _to_config does not follow it.

Worth noting the API cannot produce these states itself — every malformed write is rejected with a 422 and the file stays readable, so this needs a hand edit, a legacy file or a migration.

Steps to reproduce

P=bots/credentials/master_account/conf_client.yml
cp "$P" /tmp/conf_client.bak

python3 - <<'PY'
import yaml
p = "bots/credentials/master_account/conf_client.yml"
d = yaml.safe_load(open(p))
d["global_token"]["global_token_symbol"] = ""      # or: d["global_token"] = "nonsense"
yaml.safe_dump(d, open(p, "w"))
PY

curl -s -o /dev/null -w 'GET %{http_code}\n' -u admin:admin \
  localhost:8000/bot-orchestration/rate-oracle/config
curl -s -o /dev/null -w 'PUT %{http_code}\n' -u admin:admin -X PUT \
  -H 'Content-Type: application/json' -d '{"rate_oracle_source":{"name":"binance"}}' \
  localhost:8000/bot-orchestration/rate-oracle/config

cp /tmp/conf_client.bak "$P"

Observed: GET 500, PUT 500. Same for each shape in the table above.

🤖 Generated with Claude Code

Comment thread services/self_upgrade.py
Comment on lines +560 to +562
if expected_run_id is not None and run_id != expected_run_id:
logger.info(f"Self-upgrade helper {helper.name} is not the run this API is tracking; leaving it.")
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale helper replaces status

On boot, the service does not have an expected run ID, so every exited helper from the same Compose project passes this check. If multiple uncollected helpers have accumulated, the loop adopts and removes all of them while repeatedly overwriting _run in Docker's list order. A stale helper can therefore replace the latest upgrade result, permanently deleting the newer record and making /system/upgrade/status report the wrong run. Select one authoritative helper on boot, such as the newest, without allowing older helpers to replace its status.

@cardosofede

Copy link
Copy Markdown
Contributor Author

Thanks for the retest. Status of the two:

1. A foreign upgrade helper is adopted as this API's own run. Fixed in 9292dec. _collect_finished_helpers and _existing_helper now scope to this deployment before touching a helper:

  • Every helper is labelled with its own compose project at creation time (HELPER_PROJECT_LABEL, services/self_upgrade.py). Collection compares that label against this container's own compose project (_own_compose_project) and skips any helper that doesn't match, so an idle or unrelated API can no longer adopt or remove another stack's helper.
  • When a run is already tracked (self._run has a run_id), a helper under our own project but for a different run is left alone too, instead of overwriting the tracked run.
  • _existing_helper (used by preflight) applies the same project scoping to in-flight helpers, so it can't be blocked by another stack's helper either.
  • New tests cover the foreign-project helper, the mismatched-run-id case, and preflight scoping.

2. A hand-edited conf_client.yml makes the endpoint 500. Fixed in 96dabd6, applying the "a hand-edited file can hold anything, read as default rather than failing the whole GET" principle to the rest of _to_config (routers/bot_rate_oracle.py):

  • _read_conf_client catches yaml.YAMLError (invalid YAML) and treats a non-dict top level (e.g. a YAML list) as empty, instead of letting either reach the route as a 500.
  • A new _clean_str helper strips and validates every field _to_config reads (rate_oracle_source.name, global_token_name, global_token_symbol), falling back to the documented default when the value is blank, missing or the wrong type.
  • rate_oracle_source and global_token are validated as dicts before being read, covering the case where either was persisted as a string or other non-dict shape.
  • New tests cover each row from your table: blank global_token_symbol, non-dict global_token, non-dict rate_oracle_source, invalid YAML, and a YAML list at the top level.

Both are covered by regression tests; full suite run locally: 70 passed (42 in test_self_upgrade.py, 28 in test_bot_rate_oracle_config.py).

The hummingbot-api-client#27 follow-up you flagged is in that repo and out of scope here.

@david-hummingbot
david-hummingbot merged commit d276034 into main Sep 22, 2026
1 check passed
@rapcmia rapcmia moved this from Under Review to Hummingbot-API in Pull Request Board Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Hummingbot-API

Development

Successfully merging this pull request may close these issues.

3 participants