Troubleshooting
Common errors and how to fix them.
Startup Errors
Section titled “Startup Errors”ConfigurationError: KIRAK_AUTH_JWT_SECRET_KEY must be at least 32 bytes
Section titled “ConfigurationError: KIRAK_AUTH_JWT_SECRET_KEY must be at least 32 bytes”The secret key is too short or missing.
# Generate a secure keypython -c "import secrets; print(secrets.token_hex(32))"Set it in .env:
KIRAK_AUTH_JWT_SECRET_KEY=<generated-value>ConfigurationError: database.name is required
Section titled “ConfigurationError: database.name is required”The database name is not set in the database block of kirak.json. Only the password is an environment variable.
"database": { "host": "localhost", "name": "myapp", "user": "myuser" }DB_PASSWORD=mypasswordIf you upgraded from a version that read DB_NAME, DB_HOST and the other DB_* variables (except DB_PASSWORD), move them into kirak.json; startup logs a warning naming each one that is still set.
ConfigurationError: Invalid RLS condition
Section titled “ConfigurationError: Invalid RLS condition”An access.condition in models.json uses unsupported syntax.
The grammar only allows: field operator {variable} joined by AND/OR.
// Invalid -- subquery not allowed"condition": "id IN (SELECT id FROM allowed_ids WHERE user_id = {sub})"
// Valid"condition": "user_id = {sub}"Check access.py:_SAFE_CONDITION_RE for the exact allowed grammar.
KeyError or AttributeError on module import
Section titled “KeyError or AttributeError on module import”You’re likely importing a module that requires an environment variable before load_dotenv() runs.
# Wrong orderfrom kirak import create_kirak_app # reads env vars at import timefrom dotenv import load_dotenvload_dotenv()
# Correctfrom dotenv import load_dotenvload_dotenv()from kirak import create_kirak_appModuleNotFoundError: No module named 'asyncmy' (or asyncpg)
Section titled “ModuleNotFoundError: No module named 'asyncmy' (or asyncpg)”Install the driver for your database:
# MySQLpip install "kirak[mysql]"
# PostgreSQLpip install "kirak[postgres]"Authentication Errors
Section titled “Authentication Errors”401 INVALID_TOKEN on every request
Section titled “401 INVALID_TOKEN on every request”Cause 1: Wrong secret key. The client has a token minted with a different KIRAK_AUTH_JWT_SECRET_KEY.
Cause 2: Algorithm mismatch. Token was signed with HS384 but auth.jwt_algorithm in kirak.json is HS256.
Cause 3: Token expired. Check access_token_expire_minutes / access_token_expire_hours in kirak.json’s auth section.
Cause 4: Token blacklisted. The token’s jti was added to the blacklist (logout was called). Request a new token via /auth/refresh-token.
403 EMAIL_NOT_VERIFIED after registration
Section titled “403 EMAIL_NOT_VERIFIED after registration”Email verification is required by default. Either:
- Send the user through the email verification flow (call
/auth/verify-email?token=...). - Or disable for development by setting
"email_verification_required": falsein kirak.json’sauthsection – this is a manifest setting, not an environment variable.
403 ACCOUNT_INACTIVE
Section titled “403 ACCOUNT_INACTIVE”The user’s is_active column is false. Update it directly in the database or via an admin operation.
403 PERMISSION_DENIED on a route I should have access to
Section titled “403 PERMISSION_DENIED on a route I should have access to”- Verify the JWT role matches an entry in the model’s
accessblock. - Check if there is a wildcard (
*) fallback rule. - Check that the model has an
accessblock at all – without one, every operation is denied for every role (startup logs a warning naming the model). With one, unlisted operations are denied by default. - Confirm the RLS condition resolves – if the JWT claim named in the condition is missing from the token, the condition evaluates to
NULLwhich is treated as a deny.
429 RATE_LIMIT_EXCEEDED during development
Section titled “429 RATE_LIMIT_EXCEEDED during development”Auth endpoints have hard-coded limits. Wait for the window to expire, or switch to a different IP/email for testing.
For per-model rate limits, reduce window_seconds during development or remove the rate_limit block temporarily.
Database Errors
Section titled “Database Errors”OperationalError: too many connections
Section titled “OperationalError: too many connections”database.pool_max x worker processes exceeds max_connections on the database server.
Solutions:
- Reduce
database.pool_max. - Reduce
--workerson uvicorn. - Add a connection pooler (PgBouncer for PostgreSQL, ProxySQL for MySQL) between Kirak and the database.
OperationalError: connection closed / MySQL server has gone away
Section titled “OperationalError: connection closed / MySQL server has gone away”The connection was idle longer than the DB server’s wait_timeout and was killed.
Lower database.pool_recycle_seconds in kirak.json to recycle connections before the server closes them:
"database": { "pool_recycle_seconds": 3600 }The MySQL default wait_timeout is 8 hours (28800 seconds); recycle before that.
Migration fails: Table 'kirak_migrations' doesn't exist
Section titled “Migration fails: Table 'kirak_migrations' doesn't exist”Run kirak db init before kirak db migrate. The init command creates the tracking table.
kirak db status shows modified for a migration I haven’t changed
Section titled “kirak db status shows modified for a migration I haven’t changed”The migration file on disk doesn’t match the checksum recorded when it was applied. This happens if you edited a migration file after applying it. Kirak will not re-apply it automatically.
Options:
- If the change was intentional and you’ve applied it manually, update the checksum:
UPDATE kirak_migrations SET checksum = '<new-checksum>' WHERE name = 'migration_name';
- Create a new migration for the change instead of editing the existing file.
Bulk create fails: too many SQL parameters
Section titled “Bulk create fails: too many SQL parameters”PostgreSQL has a hard limit of 32767 parameters per query. Bulk create calculates max_safe = 32767 // num_columns per batch and enforces it. If you see this error:
- The capping logic may have been bypassed by passing a manual
batch_sizethat’s too large. - Remove the manual
batch_sizeand let Kirak calculate it automatically.
Hook Errors
Section titled “Hook Errors”Hook silently does nothing
Section titled “Hook silently does nothing”- Confirm the hook is registered inside
on_kirak_ready– hooks registered after startup have no effect. - Check the module’s log file (
logs/{module}.log) for a timeout or exception log line. - Verify the hook name matches exactly –
after_createnotafter_created.
asyncio.TimeoutError in hook logs
Section titled “asyncio.TimeoutError in hook logs”The hook exceeded hook_timeout_seconds (kirak.json, default 5.0 seconds). Kirak skips the hook and logs the timeout.
Either:
- Speed up the hook’s operation.
- Increase the timeout:
"hook_timeout_seconds": 15inkirak.json. - Move slow work to a background task (fire-and-forget).
Hook changes not taking effect
Section titled “Hook changes not taking effect”Hooks are registered at startup. After changing hook code, restart the server. With --reload, file changes should auto-restart.
Storage Errors
Section titled “Storage Errors”FileNotFoundError on local storage
Section titled “FileNotFoundError on local storage”The upload_dir of the local provider instance (kirak.json, storage.providers.<name>.upload_dir) must be writable. Kirak creates it when the router is mounted.
NoCredentialsError (S3/Wasabi)
Section titled “NoCredentialsError (S3/Wasabi)”The credentials for that provider instance are not set or lack the required S3 permissions (s3:PutObject, s3:DeleteObject, s3:GetObject). They are read from KIRAK_STORAGE_<INSTANCE>_ACCESS_KEY and KIRAK_STORAGE_<INSTANCE>_SECRET_KEY, e.g. KIRAK_STORAGE_MAIN_ACCESS_KEY for the instance named main.
The URL of an uploaded file answers 403 (S3-type storage)
Section titled “The URL of an uploaded file answers 403 (S3-type storage)”Kirak returns a URL but does not make the file public. On r2, the S3 endpoint is never public: set public_url to the bucket’s r2.dev subdomain or custom domain. On aws, wasabi and cubbit, allow public reads on the bucket (or set "acl": "public-read" where the bucket accepts ACLs). On spaces, check that acl is not private. On ovh, add a bucket policy or set "acl": "public-read". On b2, make the bucket public in Backblaze. For files that should stay private, use get_url with expires. See Storage.
Google Cloud Storage: get_url with expires fails, or check() warns about signBlob
Section titled “Google Cloud Storage: get_url with expires fails, or check() warns about signBlob”Under Application Default Credentials (no credentials_json) there is no private key to sign URLs with. Set signing_service_account on the gcs instance to a service account email, and give the app’s identity iam.serviceAccounts.signBlob on it (the Service Account Token Creator role). check() signs one URL to prove it works and reports a warning (permission_denied) when it does not.
Azure Blob Storage: file URLs answer 404 or 409, or check() warns about the Storage Blob Delegator role
Section titled “Azure Blob Storage: file URLs answer 404 or 409, or check() warns about the Storage Blob Delegator role”A blob URL answers PublicAccessNotPermitted (409) or ResourceNotFound (404) when the container does not allow anonymous access; new storage accounts disallow it at account level. Allow blob-level public access, set public_url to Front Door or a CDN, or use get_url with expires. Under an Azure identity (no connection string or account key), signed URLs need a user delegation key: give the identity the Storage Blob Delegator role. check() requests one and reports a warning (permission_denied) when it is refused.
Image upload returns 422
Section titled “Image upload returns 422”The file MIME type is not in the allowed list, or the file size exceeds image_max_size (kirak.json, storage). Check the error message for which constraint was violated.
Scheduler Errors
Section titled “Scheduler Errors”TASK_NOT_REGISTERED (400): Task ‘my_task’ is not registered
Section titled “TASK_NOT_REGISTERED (400): Task ‘my_task’ is not registered”The task name passed to enqueue() doesn’t match the name registered with @scheduler.task("my_task"). Names are case-sensitive and must match exactly.
SCHEDULER_NOT_STARTED (503)
Section titled “SCHEDULER_NOT_STARTED (503)”enqueue() was called before the scheduler connected its backends. This usually means calling enqueue() at module import time rather than inside a request handler or a hook.
Jobs are not processing
Section titled “Jobs are not processing”There is no separate kirak worker CLI command – the scheduler worker starts embedded inside the application process automatically when "scheduler" is in modules, so if jobs aren’t processing, the app process itself is the thing to check.
- Confirm the app process has
"scheduler"in its enabled modules and actually started (check startup logs for scheduler initialization). - Check the application log for worker errors.
- Verify
scheduler.default_providerand the providertypein kirak.json match what you expect – e.g. aredisprovider namedrediswithKIRAK_SCHEDULER_REDIS_URLunset will fail to connect. - For the database backend, check the
scheduler_jobstable for rows wherestatus = 'pending'andrun_at <= now().
MCP Errors
Section titled “MCP Errors”The app does not start: “Invalid MCP server files”
Section titled “The app does not start: “Invalid MCP server files””Startup lists every problem with the files in mcp/, with the file and the key. The usual ones:
a tool ref that names no model or module operation (unknown_tool_ref,
unknown_tool_operation), a module that kirak.json does not enable
(tool_module_not_enabled), an operation that cannot be a tool such as a webhook
(tool_not_callable), a JSON key written twice, or two files with the same name. See
MCP Servers.
401 or 403 from /mcp/<name>/
Section titled “401 or 403 from /mcp/<name>/”401: no credential, or one that is invalid or expired – send Authorization: Bearer <JWT or API key> or X-API-Key. 403: the caller’s role is below the server’s access, or the account
is deactivated.
A tool answers CONFIRMATION_UNAVAILABLE or CONFIRMATION_DECLINED
Section titled “A tool answers CONFIRMATION_UNAVAILABLE or CONFIRMATION_DECLINED”The tool has requires_confirmation: true. CONFIRMATION_UNAVAILABLE: the client is on an MCP
protocol older than 2026-07-28 and cannot show the confirmation form, so the tool never runs for
it – update the client. CONFIRMATION_DECLINED: the person did not approve the call. See
Confirmation.
Development Tips
Section titled “Development Tips”Logging
Section titled “Logging”Every module – Auth, CrudEngine, and all optional modules – shares one rotating log file, {log_path}/kirak.log (default logs/kirak.log; set the directory with log_path in kirak.json). There are no separate per-module log files: independent handlers rotating the same file at different moments could race and corrupt it, so all modules log to the one shared handler. Each log line includes the emitting module’s name, so you can still tell modules apart.
Set the log_level field in kirak.json (not an environment variable) for verbose output including SQL queries.
Disabling access control temporarily
Section titled “Disabling access control temporarily”During early development, remove the access block from a model entirely. Without an access block, all operations are allowed without authentication.
Never deploy to production without access blocks.
Inspecting generated routes
Section titled “Inspecting generated routes”from fastapi import FastAPIapp: FastAPI = ... # your kirak app
for route in app.routes: print(route.path, getattr(route, "methods", ""))Or read http://localhost:8000/docs (or /openapi.json) while the server is running: both list every mounted route.