Plugin Daemon Architecture#
Overview#
The plugin daemon is a separate Go-based service (default port 5002) that handles all plugin execution in Dify. The API never runs plugin code directly β every model dispatch, tool call, agent strategy execution, credential validation, and datasource operation is proxied over HTTP to the daemon .
Dify API ββ(HTTP / SSE)βββΊ Plugin Daemon :5002
headers: X-Api-Key, X-Plugin-ID
path: plugin/{tenant_id}/...
The daemon maintains its own PostgreSQL database (dify_plugin, separate from the main dify DB) and its own filesystem tree for installed plugin packages .
Entry points in the API codebase:
| File | Role |
|---|---|
api/core/plugin/impl/base.py | BasePluginClient β shared HTTP layer |
api/core/plugin/impl/model.py | Model/LLM dispatch |
api/core/plugin/impl/tool.py | Tool invocation |
api/core/plugin/impl/exc.py | Exception hierarchy |
api/configs/feature/__init__.py | PluginConfig settings |
Request Routing: Headers and URL Structure#
All requests flow through BasePluginClient in api/core/plugin/impl/base.py. Two complementary identification mechanisms are used together on every call.
Headers#
| Header | Value | Set by |
|---|---|---|
X-Api-Key | PLUGIN_DAEMON_KEY | _prepare_request() in base class, on every request |
X-Plugin-ID | Plugin unique identifier | Each subclass per dispatch call (e.g. tool.py, model.py) |
traceparent | W3C trace context | _inject_trace_headers() when ENABLE_OTEL=true |
X-Api-Key is the shared secret between the API and daemon for authentication. X-Plugin-ID scopes the dispatch to the specific installed plugin within the tenant.
URL Patterns#
All URLs follow plugin/{tenant_id}/{segment}. The tenant_id in the path scopes the request to a specific workspace; the X-Plugin-ID header identifies which plugin within that workspace handles it.
| Operation | URL |
|---|---|
| Invoke tool | plugin/{tenant_id}/dispatch/tool/invoke |
| Invoke LLM | plugin/{tenant_id}/dispatch/llm/invoke |
| Validate credentials | plugin/{tenant_id}/dispatch/model/validate_provider_credentials |
| Text embedding | plugin/{tenant_id}/dispatch/text_embedding/invoke |
| List tool providers | plugin/{tenant_id}/management/tools |
Transport and HTTP Client#
Two Transport Modes#
BasePluginClient offers two methods :
_request()β synchronous, blockinghttpx.Client.request(). Used for non-streaming operations (list plugins, validate credentials, etc.)._stream_request()β synchronous generator usinghttpx.Client.stream(), consumed line-by-line as SSE. Used for LLM inference, embeddings, and rerank.
Key constraint: Both methods are fully synchronous β they block the calling Gunicorn worker thread for the entire duration of the daemon call, including all LLM inference time. Agent nodes are especially vulnerable because they serialize multiple tool + LLM calls in a single worker hold .
HTTP Client Pool#
A module-level, process-shared httpx.Client is created once at import time via get_pooled_http_client(), capped at 50 keep-alive / 100 max connections.
Response Envelope#
Non-streaming responses are wrapped in PluginDaemonBasicResponse. A non-zero code field triggers _handle_plugin_daemon_error(). Streaming responses are newline-delimited JSON; each line is validated against the same envelope before being yielded.
Timeout Control#
The default timeout is PLUGIN_DAEMON_TIMEOUT (600 s). A context manager use_plugin_daemon_request_timeout() allows scoped per-request shortening without mutating global state.
Error Handling#
Exception Hierarchy#
Defined in api/core/plugin/impl/exc.py:
PluginDaemonError
βββ PluginDaemonInternalError
β βββ PluginDaemonInternalServerError
β βββ PluginDaemonUnauthorizedError
β βββ PluginDaemonNotFoundError
β βββ PluginRuntimeError
βββ PluginDaemonClientSideError
βββ PluginDaemonBadRequestError
βββ PluginInvokeError (also ValueError)
βββ PluginUniqueIdentifierError
βββ PluginNotFoundError
βββ PluginPermissionDeniedError
β οΈ Do not rename exception classes in
exc.py._handle_plugin_daemon_error()dispatches byerror_typestring equality against class__name__. Renaming breaks the mapping silently .
Error Dispatch#
_handle_plugin_daemon_error() uses match/case on the error type name from the daemon response. The PluginInvokeError branch further matches nested invoke_error_type to re-raise typed invoke errors such as InvokeRateLimitError, InvokeAuthorizationError, InvokeServerUnavailableError, PluginRuntimeError, etc.
Runtime-Unavailability Bug (β PR #41696)#
When the plugin daemon restarts, Redis has no plugin runtime state yet. The daemon returns HTTP 404 with message "no available node, plugin runtime not found". Before the fix :
- HTTP 404 was classified as
PluginDaemonClientSideErrorby status code alone. - Streaming paths converted
PluginDaemonInnerErrorβ bareValueErrorininvoke_llm. handle_value_errorinexternal_api.pymapped anyValueErrorβ HTTP 400invalid_param.
A valid in-flight Chatflow appeared to have bad input, not a transient infrastructure failure .
PR #41696 introduces PluginDaemonUnavailableError and maps it to HTTP 503 plugin_daemon_unavailable, so clients can distinguish retryable daemon outages from genuine bad-request errors.
Configuration#
Defined in PluginConfig (api/configs/feature/__init__.py) :
| Variable | Default | Notes |
|---|---|---|
PLUGIN_DAEMON_URL | http://localhost:5002 | Use http://plugin_daemon:5002 in Docker Compose (service name, not localhost) |
PLUGIN_DAEMON_KEY | plugin-api-key | Shared secret for X-Api-Key; rotate in production |
PLUGIN_DAEMON_TIMEOUT | 600.0 s | Set to None to disable; lower to 60β120 s to release blocked workers sooner under load |
Additional Docker-specific variables :
| Variable | Notes |
|---|---|
DB_PLUGIN_DATABASE | Daemon's separate DB (default: dify_plugin); must exist before daemon starts |
PLUGIN_MAX_EXECUTION_TIMEOUT | Per-plugin execution cap (seconds) |
PLUGIN_STORAGE_TYPE | local, S3, Azure, Aliyun OSS, Tencent COS, Volcengine TOS |
PLUGIN_INSTALLED_PATH | Subdir under storage root for installed plugins |
FORCE_VERIFYING_SIGNATURE | Enforce plugin signature verification |
PLUGIN_DIFY_INNER_API_KEY | Inner API key for plugin-to-API callbacks |
Plugin daemon container storage binds ./volumes/plugin_daemon β /app/storage inside the container.
Version Synchronization#
The plugin daemon is versioned in lockstep with the Dify API. Protocol changes between releases cause complete communication failure when versions diverge .
Version Pairings#
| Dify API | Plugin Daemon image tag |
|---|---|
| 1.13.x | 0.5.x |
| 1.14.x | 0.6.x |
| 1.15.0 | 0.6.10-local |
When mismatched, the daemon logs "failed to find the version of the plugin sdk" and the API returns connection errors .
Post-Upgrade Checklist#
After any major version bump :
- Schema migration β
docker compose exec api flask upgrade-db(applies Alembic migrations to maindifyDB; auto-runs on startup ifMIGRATION_ENABLED=true) - Plugin backfill β
docker compose exec api flask backfill-plugin-auto-upgrade(normalizes per-tenant plugin auto-upgrade strategy rows after identifier changes) - Verify daemon image tag matches the Dify API release
- Clean stale plugin dirs from
./volumes/plugin_daemon/plugin/for anyrecord not foundentries in daemon logs
Common Upgrade Failures#
| Failure | Symptom | Fix |
|---|---|---|
| Stale version dirs (1.15.0+) | record not found / Plugin table missing in daemon logs | Run backfill command; remove stale dirs |
model_type enum mismatch (1.14.x) | HTTP 400 "Credential with id β¦ not found" | Manual SQL UPDATE on provider tables |
Duplicate plugin_unique_identifier (1.13.xβ1.14.x) | Daemon restart loop, SQLSTATE 23505 | Deduplicate dify_plugin.plugins table |