litellm/tests/litellm-proxy-extras/test_litellm_proxy_extras_utils.py
yuneng-jiang d41e5e29ce Extract _deploy_with_idempotent_resolution loop for P3009/P3018
The previous approach resolved one idempotent migration per outer retry
attempt, exhausting all 5 retries when a DB had 4+ idempotent failures
(e.g., tables/columns already created by the old force-apply logic).

Now _deploy_with_idempotent_resolution loops internally: deploy → detect
idempotent P3009/P3018 → rollback + resolve → re-deploy, until all
idempotent migrations are resolved or a non-recoverable error is hit.
This keeps outer retries available for transient errors (timeouts, etc).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:29:42 -07:00

344 lines
15 KiB
Python

import os
import subprocess
import sys
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(
0,
os.path.abspath(
os.path.join(os.path.dirname(__file__), "../../litellm-proxy-extras")
),
)
from litellm_proxy_extras.utils import ProxyExtrasDBManager
def test_custom_prisma_dir(monkeypatch):
import tempfile
# create a temp directory
temp_dir = tempfile.mkdtemp()
monkeypatch.setenv("LITELLM_MIGRATION_DIR", temp_dir)
## Check if the prisma dir is the temp directory
assert ProxyExtrasDBManager._get_prisma_dir() == temp_dir
## Check if the schema.prisma file is in the temp directory
schema_path = os.path.join(temp_dir, "schema.prisma")
assert os.path.exists(schema_path)
## Check if the migrations dir is in the temp directory
migrations_dir = os.path.join(temp_dir, "migrations")
assert os.path.exists(migrations_dir)
class TestPermissionErrorDetection:
"""Test cases for permission error detection in Prisma migrations"""
def test_is_permission_error_postgres_42501(self):
"""Test detection of PostgreSQL 42501 error code (insufficient privilege)"""
error_message = "Database error code: 42501 - permission denied for table users"
assert ProxyExtrasDBManager._is_permission_error(error_message) is True
def test_is_permission_error_must_be_owner(self):
"""Test detection of 'must be owner of table' error"""
error_message = "ERROR: must be owner of table my_table"
assert ProxyExtrasDBManager._is_permission_error(error_message) is True
def test_is_permission_error_permission_denied_schema(self):
"""Test detection of 'permission denied for schema' error"""
error_message = "permission denied for schema public"
assert ProxyExtrasDBManager._is_permission_error(error_message) is True
def test_is_permission_error_permission_denied_table(self):
"""Test detection of 'permission denied for table' error"""
error_message = "permission denied for table my_table"
assert ProxyExtrasDBManager._is_permission_error(error_message) is True
def test_is_permission_error_must_be_owner_schema(self):
"""Test detection of 'must be owner of schema' error"""
error_message = "must be owner of schema public"
assert ProxyExtrasDBManager._is_permission_error(error_message) is True
def test_is_permission_error_case_insensitive(self):
"""Test that permission error detection is case insensitive"""
error_message = "PERMISSION DENIED FOR TABLE my_table"
assert ProxyExtrasDBManager._is_permission_error(error_message) is True
def test_is_permission_error_negative(self):
"""Test that non-permission errors are not detected as permission errors"""
error_message = "column 'id' already exists"
assert ProxyExtrasDBManager._is_permission_error(error_message) is False
class TestIdempotentErrorDetection:
"""Test cases for idempotent error detection in Prisma migrations"""
def test_is_idempotent_error_already_exists(self):
"""Test detection of generic 'already exists' error"""
error_message = "object already exists"
assert ProxyExtrasDBManager._is_idempotent_error(error_message) is True
def test_is_idempotent_error_column_already_exists(self):
"""Test detection of 'column already exists' error"""
error_message = "column 'email' already exists"
assert ProxyExtrasDBManager._is_idempotent_error(error_message) is True
def test_is_idempotent_error_duplicate_key(self):
"""Test detection of duplicate key violation error"""
error_message = "duplicate key value violates unique constraint"
assert ProxyExtrasDBManager._is_idempotent_error(error_message) is True
def test_is_idempotent_error_relation_already_exists(self):
"""Test detection of 'relation already exists' error"""
error_message = "relation 'users_pkey' already exists"
assert ProxyExtrasDBManager._is_idempotent_error(error_message) is True
def test_is_idempotent_error_constraint_already_exists(self):
"""Test detection of 'constraint already exists' error"""
error_message = "constraint 'fk_user_id' already exists"
assert ProxyExtrasDBManager._is_idempotent_error(error_message) is True
def test_is_idempotent_error_does_not_exist(self):
"""Test detection of 'does not exist' error"""
error_message = "ERROR: index 'idx' does not exist"
assert ProxyExtrasDBManager._is_idempotent_error(error_message) is True
def test_is_idempotent_error_case_insensitive(self):
"""Test that idempotent error detection is case insensitive"""
error_message = "COLUMN 'ID' ALREADY EXISTS"
assert ProxyExtrasDBManager._is_idempotent_error(error_message) is True
def test_is_idempotent_error_does_not_exist(self):
"""Test detection of 'does not exist' error"""
error_message = "ERROR: index 'idx' does not exist"
assert ProxyExtrasDBManager._is_idempotent_error(error_message) is True
def test_is_idempotent_error_negative(self):
"""Test that non-idempotent errors are not detected as idempotent errors"""
error_message = "Database error code: 42501 - permission denied"
assert ProxyExtrasDBManager._is_idempotent_error(error_message) is False
class TestErrorClassificationPriority:
"""Test cases to ensure errors are correctly classified"""
def test_permission_error_not_classified_as_idempotent(self):
"""Ensure permission errors are not mistakenly classified as idempotent"""
error_message = "Database error code: 42501 - must be owner of table users"
assert ProxyExtrasDBManager._is_permission_error(error_message) is True
assert ProxyExtrasDBManager._is_idempotent_error(error_message) is False
def test_idempotent_error_not_classified_as_permission(self):
"""Ensure idempotent errors are not mistakenly classified as permission errors"""
error_message = "column 'created_at' already exists"
assert ProxyExtrasDBManager._is_idempotent_error(error_message) is True
assert ProxyExtrasDBManager._is_permission_error(error_message) is False
def test_unknown_error_classified_as_neither(self):
"""Ensure unknown errors are classified as neither permission nor idempotent"""
error_message = "connection timeout"
assert ProxyExtrasDBManager._is_permission_error(error_message) is False
assert ProxyExtrasDBManager._is_idempotent_error(error_message) is False
class TestMarkAllMigrationsApplied:
"""Test that _mark_all_migrations_applied only marks migrations without applying diffs"""
@patch("litellm_proxy_extras.utils.subprocess.run")
@patch.object(
ProxyExtrasDBManager,
"_get_migration_names",
return_value=["20250326162113_baseline", "20250329084805_new_cron_job_table"],
)
def test_marks_each_migration_as_applied(self, mock_get_names, mock_run):
"""Verify each migration is marked as applied via prisma migrate resolve"""
ProxyExtrasDBManager._mark_all_migrations_applied("/fake/migrations/dir")
assert mock_run.call_count == 2
for call_args in mock_run.call_args_list:
cmd = call_args[0][0]
assert "migrate" in cmd
assert "resolve" in cmd
assert "--applied" in cmd
@patch("litellm_proxy_extras.utils.subprocess.run")
@patch.object(
ProxyExtrasDBManager,
"_get_migration_names",
return_value=["20250326162113_baseline"],
)
def test_does_not_generate_or_apply_diffs(self, mock_get_names, mock_run):
"""Verify no diff generation or db execute commands are run"""
ProxyExtrasDBManager._mark_all_migrations_applied("/fake/migrations/dir")
for call_args in mock_run.call_args_list:
cmd = call_args[0][0]
# Should never run diff or db execute
assert "diff" not in cmd
assert "execute" not in cmd
assert "push" not in cmd
@patch("litellm_proxy_extras.utils.subprocess.run")
@patch.object(
ProxyExtrasDBManager,
"_get_migration_names",
return_value=["20250326162113_baseline"],
)
def test_skips_already_applied_migration(self, mock_get_names, mock_run):
"""Verify already-applied migrations are silently skipped"""
mock_run.side_effect = subprocess.CalledProcessError(
1,
"prisma",
stderr="Migration `20250326162113_baseline` is already recorded as applied in the database.",
)
# Should not raise
ProxyExtrasDBManager._mark_all_migrations_applied("/fake/migrations/dir")
@patch("litellm_proxy_extras.utils.subprocess.run")
@patch.object(
ProxyExtrasDBManager,
"_get_migration_names",
return_value=["20250326162113_baseline"],
)
def test_raises_on_unexpected_error(self, mock_get_names, mock_run):
"""Verify non-'already applied' errors are propagated, not silently swallowed"""
mock_run.side_effect = subprocess.CalledProcessError(
1,
"prisma",
stderr="connection refused",
)
with pytest.raises(RuntimeError, match="Failed to mark migration"):
ProxyExtrasDBManager._mark_all_migrations_applied("/fake/migrations/dir")
class TestDeployWithIdempotentResolution:
"""Test _deploy_with_idempotent_resolution loops through multiple idempotent failures"""
@patch("litellm_proxy_extras.utils.subprocess.run")
def test_resolves_multiple_idempotent_migrations_in_one_pass(self, mock_run):
"""Multiple P3018 idempotent errors should all be resolved without consuming outer retries"""
p3018_error_1 = subprocess.CalledProcessError(
1,
"prisma",
stderr="P3018\nMigration name: 20251113000000_add_project_table\nERROR: relation \"LiteLLM_ProjectTable\" already exists",
output="",
)
p3018_error_2 = subprocess.CalledProcessError(
1,
"prisma",
stderr="P3018\nMigration name: 20251113000001_add_project_fields\nERROR: column \"description\" already exists",
output="",
)
# deploy fails, rollback, resolve, deploy fails again, rollback, resolve, deploy succeeds
mock_run.side_effect = [
p3018_error_1,
MagicMock(returncode=0), # roll_back
MagicMock(returncode=0), # resolve
p3018_error_2,
MagicMock(returncode=0), # roll_back
MagicMock(returncode=0), # resolve
MagicMock(stdout="All migrations applied", returncode=0), # final deploy
]
ProxyExtrasDBManager._deploy_with_idempotent_resolution()
assert mock_run.call_count == 7
# First, fourth, and seventh calls should be deploy
for idx in [0, 3, 6]:
cmd = mock_run.call_args_list[idx][0][0]
assert cmd == ["prisma", "migrate", "deploy"]
@patch("litellm_proxy_extras.utils.subprocess.run")
def test_p3009_non_idempotent_raises_runtime_error(self, mock_run):
"""P3009 with non-idempotent error should raise RuntimeError"""
deploy_error = subprocess.CalledProcessError(
1,
"prisma",
stderr="P3009: migrate found failed migrations in the target database, `20250329084805_new_cron_job_table` migration. Error: syntax error at or near 'ALTR'",
output="",
)
mock_run.side_effect = [deploy_error, MagicMock(returncode=0)]
with pytest.raises(RuntimeError, match="requires manual intervention"):
ProxyExtrasDBManager._deploy_with_idempotent_resolution()
# deploy + rollback
assert mock_run.call_count == 2
rollback_cmd = mock_run.call_args_list[1][0][0]
assert "--rolled-back" in rollback_cmd
@patch("litellm_proxy_extras.utils.subprocess.run")
def test_p3009_unmatched_regex_raises_runtime_error(self, mock_run):
"""P3009 with unparseable migration name should fail fast"""
error = subprocess.CalledProcessError(
1,
"prisma",
stderr="P3009: migrate found failed migrations in the target database, unexpected format",
output="",
)
mock_run.side_effect = error
with pytest.raises(RuntimeError, match="could not extract migration name"):
ProxyExtrasDBManager._deploy_with_idempotent_resolution()
assert mock_run.call_count == 1
@patch("litellm_proxy_extras.utils.subprocess.run")
def test_p3009_idempotent_redeploys(self, mock_run):
"""P3009 with idempotent error should resolve then re-deploy"""
deploy_error = subprocess.CalledProcessError(
1,
"prisma",
stderr="P3009: migrate found failed migrations in the target database, `20250329084805_new_cron_job_table` migration. Error: column 'status' already exists",
output="",
)
mock_run.side_effect = [
deploy_error,
MagicMock(returncode=0), # roll_back
MagicMock(returncode=0), # resolve
MagicMock(stdout="All migrations applied", returncode=0), # re-deploy
]
ProxyExtrasDBManager._deploy_with_idempotent_resolution()
assert mock_run.call_count == 4
last_cmd = mock_run.call_args_list[3][0][0]
assert last_cmd == ["prisma", "migrate", "deploy"]
@patch("litellm_proxy_extras.utils.subprocess.run")
def test_p3018_permission_error_raises(self, mock_run):
"""P3018 with permission error should raise RuntimeError"""
deploy_error = subprocess.CalledProcessError(
1,
"prisma",
stderr="P3018\nMigration name: 20251113000000_add_project_table\nDatabase error code: 42501\npermission denied for table users",
output="",
)
mock_run.side_effect = [deploy_error, MagicMock(returncode=0)]
with pytest.raises(RuntimeError, match="permission error"):
ProxyExtrasDBManager._deploy_with_idempotent_resolution()
class TestSetupDatabase:
"""Test setup_database integration with deploy and resolution"""
@patch("litellm_proxy_extras.utils.os.chdir")
@patch("litellm_proxy_extras.utils.os.getcwd", return_value="/original")
@patch.object(
ProxyExtrasDBManager, "_get_prisma_dir", return_value="/fake/prisma/dir"
)
@patch("litellm_proxy_extras.utils.subprocess.run")
def test_successful_deploy(self, mock_run, mock_dir, mock_getcwd, mock_chdir):
"""After successful prisma migrate deploy, no diff/resolve should be called"""
mock_run.return_value = MagicMock(stdout="All migrations applied", returncode=0)
result = ProxyExtrasDBManager.setup_database(use_migrate=True)
assert result is True
assert mock_run.call_count == 1
cmd = mock_run.call_args[0][0]
assert cmd == ["prisma", "migrate", "deploy"]