pytest-neon 2.1.3__tar.gz → 2.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/PKG-INFO +16 -1
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/README.md +15 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/pyproject.toml +1 -1
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/src/pytest_neon/__init__.py +1 -1
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/src/pytest_neon/plugin.py +349 -62
- pytest_neon-2.2.0/tests/test_branch_name_prefix.py +328 -0
- pytest_neon-2.2.0/tests/test_default_branch_safety.py +82 -0
- pytest_neon-2.2.0/tests/test_reset_behavior.py +581 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/uv.lock +1 -1
- pytest_neon-2.1.3/tests/test_reset_behavior.py +0 -378
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/.env.example +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/.github/workflows/release.yml +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/.github/workflows/tests.yml +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/.gitignore +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/.neon +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/CLAUDE.md +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/LICENSE +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/src/pytest_neon/py.typed +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/conftest.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/test_branch_lifecycle.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/test_cli_options.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/test_env_var.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/test_fixture_errors.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/test_integration.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/test_migrations.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/test_readwrite_readonly_fixtures.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/test_skip_behavior.py +0 -0
- {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/test_xdist_worker_support.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-neon
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Pytest plugin for Neon database branch isolation in tests
|
|
5
5
|
Project-URL: Homepage, https://github.com/ZainRizvi/pytest-neon
|
|
6
6
|
Project-URL: Repository, https://github.com/ZainRizvi/pytest-neon
|
|
@@ -489,6 +489,21 @@ The `neon_branch_readwrite` fixture uses Neon's branch restore API to reset data
|
|
|
489
489
|
|
|
490
490
|
This is similar to database transactions but at the branch level.
|
|
491
491
|
|
|
492
|
+
## Branch Naming
|
|
493
|
+
|
|
494
|
+
Branches are automatically named to help identify their source:
|
|
495
|
+
|
|
496
|
+
```
|
|
497
|
+
pytest-[git-branch]-[random]-[suffix]
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
**Examples:**
|
|
501
|
+
- `pytest-main-a1b2-migrated` - Migration branch from `main`
|
|
502
|
+
- `pytest-feature-auth-c3d4-test-main` - Test branch from `feature/auth`
|
|
503
|
+
- `pytest-a1b2-migrated` - When not in a git repo
|
|
504
|
+
|
|
505
|
+
The git branch name is sanitized (only `a-z`, `0-9`, `-`, `_` allowed) and truncated to 15 characters. This makes it easy to identify orphaned branches in the Neon console.
|
|
506
|
+
|
|
492
507
|
## Parallel Test Execution (pytest-xdist)
|
|
493
508
|
|
|
494
509
|
This plugin supports parallel test execution with [pytest-xdist](https://pytest-xdist.readthedocs.io/). Each xdist worker automatically gets its own isolated branch.
|
|
@@ -444,6 +444,21 @@ The `neon_branch_readwrite` fixture uses Neon's branch restore API to reset data
|
|
|
444
444
|
|
|
445
445
|
This is similar to database transactions but at the branch level.
|
|
446
446
|
|
|
447
|
+
## Branch Naming
|
|
448
|
+
|
|
449
|
+
Branches are automatically named to help identify their source:
|
|
450
|
+
|
|
451
|
+
```
|
|
452
|
+
pytest-[git-branch]-[random]-[suffix]
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
**Examples:**
|
|
456
|
+
- `pytest-main-a1b2-migrated` - Migration branch from `main`
|
|
457
|
+
- `pytest-feature-auth-c3d4-test-main` - Test branch from `feature/auth`
|
|
458
|
+
- `pytest-a1b2-migrated` - When not in a git repo
|
|
459
|
+
|
|
460
|
+
The git branch name is sanitized (only `a-z`, `0-9`, `-`, `_` allowed) and truncated to 15 characters. This makes it easy to identify orphaned branches in the Neon console.
|
|
461
|
+
|
|
447
462
|
## Parallel Test Execution (pytest-xdist)
|
|
448
463
|
|
|
449
464
|
This plugin supports parallel test execution with [pytest-xdist](https://pytest-xdist.readthedocs.io/). Each xdist worker automatically gets its own isolated branch.
|
|
@@ -37,25 +37,193 @@ from __future__ import annotations
|
|
|
37
37
|
|
|
38
38
|
import contextlib
|
|
39
39
|
import os
|
|
40
|
+
import random
|
|
40
41
|
import time
|
|
41
42
|
import warnings
|
|
42
|
-
from collections.abc import Generator
|
|
43
|
+
from collections.abc import Callable, Generator
|
|
43
44
|
from dataclasses import dataclass
|
|
44
45
|
from datetime import datetime, timedelta, timezone
|
|
45
|
-
from typing import Any
|
|
46
|
+
from typing import Any, TypeVar
|
|
46
47
|
|
|
47
48
|
import pytest
|
|
48
49
|
import requests
|
|
49
50
|
from neon_api import NeonAPI
|
|
51
|
+
from neon_api.exceptions import NeonAPIError
|
|
50
52
|
from neon_api.schema import EndpointState
|
|
51
53
|
|
|
54
|
+
T = TypeVar("T")
|
|
55
|
+
|
|
52
56
|
# Default branch expiry in seconds (10 minutes)
|
|
53
57
|
DEFAULT_BRANCH_EXPIRY_SECONDS = 600
|
|
54
58
|
|
|
59
|
+
# Rate limit retry configuration
|
|
60
|
+
# See: https://api-docs.neon.tech/reference/api-rate-limiting
|
|
61
|
+
# Neon limits: 700 requests/minute (~11/sec), burst up to 40/sec per route
|
|
62
|
+
_RATE_LIMIT_BASE_DELAY = 4.0 # seconds
|
|
63
|
+
_RATE_LIMIT_MAX_TOTAL_DELAY = 90.0 # 1.5 minutes total cap
|
|
64
|
+
_RATE_LIMIT_JITTER_FACTOR = 0.25 # +/- 25% jitter
|
|
65
|
+
_RATE_LIMIT_MAX_ATTEMPTS = 10 # Maximum number of retry attempts
|
|
66
|
+
|
|
55
67
|
# Sentinel value to detect when neon_apply_migrations was not overridden
|
|
56
68
|
_MIGRATIONS_NOT_DEFINED = object()
|
|
57
69
|
|
|
58
70
|
|
|
71
|
+
class NeonRateLimitError(Exception):
|
|
72
|
+
"""Raised when Neon API rate limit is exceeded and retries are exhausted."""
|
|
73
|
+
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _calculate_retry_delay(
|
|
78
|
+
attempt: int,
|
|
79
|
+
base_delay: float = _RATE_LIMIT_BASE_DELAY,
|
|
80
|
+
jitter_factor: float = _RATE_LIMIT_JITTER_FACTOR,
|
|
81
|
+
) -> float:
|
|
82
|
+
"""
|
|
83
|
+
Calculate delay for a retry attempt with exponential backoff and jitter.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
attempt: The retry attempt number (0-indexed)
|
|
87
|
+
base_delay: Base delay in seconds
|
|
88
|
+
jitter_factor: Jitter factor (0.25 means +/- 25%)
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Delay in seconds with jitter applied
|
|
92
|
+
"""
|
|
93
|
+
# Exponential backoff: base_delay * 2^attempt
|
|
94
|
+
delay = base_delay * (2**attempt)
|
|
95
|
+
|
|
96
|
+
# Apply jitter: delay * (1 +/- jitter_factor)
|
|
97
|
+
jitter = delay * jitter_factor * (2 * random.random() - 1)
|
|
98
|
+
return delay + jitter
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _is_rate_limit_error(exc: Exception) -> bool:
|
|
102
|
+
"""
|
|
103
|
+
Check if an exception indicates a rate limit (429) error.
|
|
104
|
+
|
|
105
|
+
Handles both requests.HTTPError (with response object) and NeonAPIError
|
|
106
|
+
(which only has the error text, not the response object).
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
exc: The exception to check
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
True if this is a rate limit error, False otherwise
|
|
113
|
+
"""
|
|
114
|
+
# Check NeonAPIError first - it inherits from HTTPError but doesn't have
|
|
115
|
+
# a response object, so we need to check the error text
|
|
116
|
+
if isinstance(exc, NeonAPIError):
|
|
117
|
+
# NeonAPIError doesn't preserve the response object, only the text
|
|
118
|
+
# Check for rate limit indicators in the error message
|
|
119
|
+
# Note: We use "too many requests" specifically to avoid false positives
|
|
120
|
+
# from errors like "too many connections" or "too many rows"
|
|
121
|
+
error_text = str(exc).lower()
|
|
122
|
+
return (
|
|
123
|
+
"429" in error_text
|
|
124
|
+
or "rate limit" in error_text
|
|
125
|
+
or "too many requests" in error_text
|
|
126
|
+
)
|
|
127
|
+
if isinstance(exc, requests.HTTPError):
|
|
128
|
+
return exc.response is not None and exc.response.status_code == 429
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _get_retry_after_from_error(exc: Exception) -> float | None:
|
|
133
|
+
"""
|
|
134
|
+
Extract Retry-After header value from an exception if available.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
exc: The exception to check
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
The Retry-After value in seconds, or None if not available
|
|
141
|
+
"""
|
|
142
|
+
if isinstance(exc, requests.HTTPError) and exc.response is not None:
|
|
143
|
+
retry_after = exc.response.headers.get("Retry-After")
|
|
144
|
+
if retry_after:
|
|
145
|
+
try:
|
|
146
|
+
return float(retry_after)
|
|
147
|
+
except ValueError:
|
|
148
|
+
pass
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _retry_on_rate_limit(
|
|
153
|
+
operation: Callable[[], T],
|
|
154
|
+
operation_name: str,
|
|
155
|
+
base_delay: float = _RATE_LIMIT_BASE_DELAY,
|
|
156
|
+
max_total_delay: float = _RATE_LIMIT_MAX_TOTAL_DELAY,
|
|
157
|
+
jitter_factor: float = _RATE_LIMIT_JITTER_FACTOR,
|
|
158
|
+
max_attempts: int = _RATE_LIMIT_MAX_ATTEMPTS,
|
|
159
|
+
) -> T:
|
|
160
|
+
"""
|
|
161
|
+
Execute an operation with retry logic for rate limit (429) errors.
|
|
162
|
+
|
|
163
|
+
Uses exponential backoff with jitter. Retries until the operation succeeds,
|
|
164
|
+
the total delay exceeds max_total_delay, or max_attempts is reached.
|
|
165
|
+
|
|
166
|
+
See: https://api-docs.neon.tech/reference/api-rate-limiting
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
operation: Callable that may raise requests.HTTPError or NeonAPIError
|
|
170
|
+
operation_name: Human-readable name for error messages
|
|
171
|
+
base_delay: Base delay in seconds for first retry
|
|
172
|
+
max_total_delay: Maximum total delay across all retries
|
|
173
|
+
jitter_factor: Jitter factor for randomization
|
|
174
|
+
max_attempts: Maximum number of retry attempts
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
The result of the operation
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
NeonRateLimitError: If rate limit retries are exhausted
|
|
181
|
+
requests.HTTPError: For non-429 HTTP errors
|
|
182
|
+
NeonAPIError: For non-429 API errors
|
|
183
|
+
Exception: For other errors from the operation
|
|
184
|
+
"""
|
|
185
|
+
total_delay = 0.0
|
|
186
|
+
attempt = 0
|
|
187
|
+
|
|
188
|
+
while True:
|
|
189
|
+
try:
|
|
190
|
+
return operation()
|
|
191
|
+
except (requests.HTTPError, NeonAPIError) as e:
|
|
192
|
+
if _is_rate_limit_error(e):
|
|
193
|
+
# Check for Retry-After header (may be added by Neon in future)
|
|
194
|
+
retry_after = _get_retry_after_from_error(e)
|
|
195
|
+
if retry_after is not None:
|
|
196
|
+
# Ensure minimum delay to prevent infinite loops if Retry-After is 0
|
|
197
|
+
delay = max(retry_after, 0.1)
|
|
198
|
+
else:
|
|
199
|
+
delay = _calculate_retry_delay(attempt, base_delay, jitter_factor)
|
|
200
|
+
|
|
201
|
+
# Check if we've exceeded max total delay
|
|
202
|
+
if total_delay + delay > max_total_delay:
|
|
203
|
+
raise NeonRateLimitError(
|
|
204
|
+
f"Rate limit exceeded for {operation_name}. "
|
|
205
|
+
f"Max total delay ({max_total_delay:.1f}s) reached after "
|
|
206
|
+
f"{attempt + 1} attempts. "
|
|
207
|
+
f"See: https://api-docs.neon.tech/reference/api-rate-limiting"
|
|
208
|
+
) from e
|
|
209
|
+
|
|
210
|
+
# Check if we've exceeded max attempts
|
|
211
|
+
attempt += 1
|
|
212
|
+
if attempt >= max_attempts:
|
|
213
|
+
raise NeonRateLimitError(
|
|
214
|
+
f"Rate limit exceeded for {operation_name}. "
|
|
215
|
+
f"Max attempts ({max_attempts}) reached after "
|
|
216
|
+
f"{total_delay:.1f}s total delay. "
|
|
217
|
+
f"See: https://api-docs.neon.tech/reference/api-rate-limiting"
|
|
218
|
+
) from e
|
|
219
|
+
|
|
220
|
+
time.sleep(delay)
|
|
221
|
+
total_delay += delay
|
|
222
|
+
else:
|
|
223
|
+
# Non-429 error, re-raise immediately
|
|
224
|
+
raise
|
|
225
|
+
|
|
226
|
+
|
|
59
227
|
def _get_xdist_worker_id() -> str:
|
|
60
228
|
"""
|
|
61
229
|
Get the pytest-xdist worker ID, or "main" if not running under xdist.
|
|
@@ -67,6 +235,50 @@ def _get_xdist_worker_id() -> str:
|
|
|
67
235
|
return os.environ.get("PYTEST_XDIST_WORKER", "main")
|
|
68
236
|
|
|
69
237
|
|
|
238
|
+
def _sanitize_branch_name(name: str) -> str:
|
|
239
|
+
"""
|
|
240
|
+
Sanitize a string for use in Neon branch names.
|
|
241
|
+
|
|
242
|
+
Only allows alphanumeric characters, hyphens, and underscores.
|
|
243
|
+
All other characters (including non-ASCII) are replaced with hyphens.
|
|
244
|
+
"""
|
|
245
|
+
import re
|
|
246
|
+
|
|
247
|
+
# Replace anything that's not alphanumeric, hyphen, or underscore with hyphen
|
|
248
|
+
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "-", name)
|
|
249
|
+
# Collapse multiple hyphens into one
|
|
250
|
+
sanitized = re.sub(r"-+", "-", sanitized)
|
|
251
|
+
# Remove leading/trailing hyphens
|
|
252
|
+
sanitized = sanitized.strip("-")
|
|
253
|
+
return sanitized
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _get_git_branch_name() -> str | None:
|
|
257
|
+
"""
|
|
258
|
+
Get the current git branch name (sanitized), or None if not in a git repo.
|
|
259
|
+
|
|
260
|
+
Used to include the git branch in Neon branch names, making it easier
|
|
261
|
+
to identify which git branch/PR created orphaned test branches.
|
|
262
|
+
|
|
263
|
+
The branch name is sanitized to replace special characters with hyphens.
|
|
264
|
+
"""
|
|
265
|
+
import subprocess
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
result = subprocess.run(
|
|
269
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
270
|
+
capture_output=True,
|
|
271
|
+
text=True,
|
|
272
|
+
timeout=5,
|
|
273
|
+
)
|
|
274
|
+
if result.returncode == 0:
|
|
275
|
+
branch = result.stdout.strip()
|
|
276
|
+
return _sanitize_branch_name(branch) if branch else None
|
|
277
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
278
|
+
pass
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
|
|
70
282
|
def _get_schema_fingerprint(connection_string: str) -> tuple[tuple[Any, ...], ...]:
|
|
71
283
|
"""
|
|
72
284
|
Get a fingerprint of the database schema for change detection.
|
|
@@ -110,6 +322,35 @@ class NeonBranch:
|
|
|
110
322
|
parent_id: str | None = None
|
|
111
323
|
|
|
112
324
|
|
|
325
|
+
def _get_default_branch_id(neon: NeonAPI, project_id: str) -> str | None:
|
|
326
|
+
"""
|
|
327
|
+
Get the default/primary branch ID for a project.
|
|
328
|
+
|
|
329
|
+
This is used as a safety check to ensure we never accidentally
|
|
330
|
+
perform destructive operations (like password reset) on the
|
|
331
|
+
production branch.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
The branch ID of the default branch, or None if not found.
|
|
335
|
+
"""
|
|
336
|
+
try:
|
|
337
|
+
# Wrap in retry logic to handle rate limits
|
|
338
|
+
# See: https://api-docs.neon.tech/reference/api-rate-limiting
|
|
339
|
+
response = _retry_on_rate_limit(
|
|
340
|
+
lambda: neon.branches(project_id=project_id),
|
|
341
|
+
operation_name="list_branches",
|
|
342
|
+
)
|
|
343
|
+
for branch in response.branches:
|
|
344
|
+
# Check both 'default' and 'primary' flags for compatibility
|
|
345
|
+
if getattr(branch, "default", False) or getattr(branch, "primary", False):
|
|
346
|
+
return branch.id
|
|
347
|
+
except Exception:
|
|
348
|
+
# If we can't fetch branches, don't block - the safety check
|
|
349
|
+
# will be skipped but tests can still run
|
|
350
|
+
pass
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
|
|
113
354
|
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
114
355
|
"""Add Neon-specific command line options and ini settings."""
|
|
115
356
|
group = parser.getgroup("neon", "Neon database branching")
|
|
@@ -279,8 +520,21 @@ def _create_neon_branch(
|
|
|
279
520
|
|
|
280
521
|
neon = NeonAPI(api_key=api_key)
|
|
281
522
|
|
|
523
|
+
# Cache the default branch ID for safety checks (only fetch once per session)
|
|
524
|
+
if not hasattr(config, "_neon_default_branch_id"):
|
|
525
|
+
config._neon_default_branch_id = _get_default_branch_id(neon, project_id) # type: ignore[attr-defined]
|
|
526
|
+
|
|
282
527
|
# Generate unique branch name
|
|
283
|
-
|
|
528
|
+
# Format: pytest-[git branch (first 15 chars)]-[random]-[suffix]
|
|
529
|
+
# This helps identify orphaned branches by showing which git branch created them
|
|
530
|
+
random_suffix = os.urandom(2).hex() # 2 bytes = 4 hex chars
|
|
531
|
+
git_branch = _get_git_branch_name()
|
|
532
|
+
if git_branch:
|
|
533
|
+
# Truncate git branch to 15 chars to keep branch names reasonable
|
|
534
|
+
git_prefix = git_branch[:15]
|
|
535
|
+
branch_name = f"pytest-{git_prefix}-{random_suffix}{branch_name_suffix}"
|
|
536
|
+
else:
|
|
537
|
+
branch_name = f"pytest-{random_suffix}{branch_name_suffix}"
|
|
284
538
|
|
|
285
539
|
# Build branch creation payload
|
|
286
540
|
branch_config: dict[str, Any] = {"name": branch_name}
|
|
@@ -294,10 +548,15 @@ def _create_neon_branch(
|
|
|
294
548
|
branch_config["expires_at"] = expires_at.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
295
549
|
|
|
296
550
|
# Create branch with compute endpoint
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
551
|
+
# Wrap in retry logic to handle rate limits
|
|
552
|
+
# See: https://api-docs.neon.tech/reference/api-rate-limiting
|
|
553
|
+
result = _retry_on_rate_limit(
|
|
554
|
+
lambda: neon.branch_create(
|
|
555
|
+
project_id=project_id,
|
|
556
|
+
branch=branch_config,
|
|
557
|
+
endpoints=[{"type": "read_write"}],
|
|
558
|
+
),
|
|
559
|
+
operation_name="branch_create",
|
|
301
560
|
)
|
|
302
561
|
|
|
303
562
|
branch = result.branch
|
|
@@ -321,8 +580,11 @@ def _create_neon_branch(
|
|
|
321
580
|
waited = 0.0
|
|
322
581
|
|
|
323
582
|
while True:
|
|
324
|
-
|
|
325
|
-
|
|
583
|
+
# Wrap in retry logic to handle rate limits during polling
|
|
584
|
+
# See: https://api-docs.neon.tech/reference/api-rate-limiting
|
|
585
|
+
endpoint_response = _retry_on_rate_limit(
|
|
586
|
+
lambda: neon.endpoint(project_id=project_id, endpoint_id=endpoint_id),
|
|
587
|
+
operation_name="endpoint_status",
|
|
326
588
|
)
|
|
327
589
|
endpoint = endpoint_response.endpoint
|
|
328
590
|
state = endpoint.current_state
|
|
@@ -341,12 +603,29 @@ def _create_neon_branch(
|
|
|
341
603
|
|
|
342
604
|
host = endpoint.host
|
|
343
605
|
|
|
606
|
+
# SAFETY CHECK: Ensure we never reset password on the default/production branch
|
|
607
|
+
# This should be impossible since we just created this branch, but we check
|
|
608
|
+
# defensively to prevent catastrophic mistakes if there's ever a bug
|
|
609
|
+
default_branch_id = getattr(config, "_neon_default_branch_id", None)
|
|
610
|
+
if default_branch_id and branch.id == default_branch_id:
|
|
611
|
+
raise RuntimeError(
|
|
612
|
+
f"SAFETY CHECK FAILED: Attempted to reset password on default branch "
|
|
613
|
+
f"{branch.id}. This should never happen - the plugin creates new "
|
|
614
|
+
f"branches and should never operate on the default branch. "
|
|
615
|
+
f"Please report this bug at https://github.com/ZainRizvi/pytest-neon/issues"
|
|
616
|
+
)
|
|
617
|
+
|
|
344
618
|
# Reset password to get the password value
|
|
345
619
|
# (newly created branches don't expose password)
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
620
|
+
# Wrap in retry logic to handle rate limits
|
|
621
|
+
# See: https://api-docs.neon.tech/reference/api-rate-limiting
|
|
622
|
+
password_response = _retry_on_rate_limit(
|
|
623
|
+
lambda: neon.role_password_reset(
|
|
624
|
+
project_id=project_id,
|
|
625
|
+
branch_id=branch.id,
|
|
626
|
+
role_name=role_name,
|
|
627
|
+
),
|
|
628
|
+
operation_name="role_password_reset",
|
|
350
629
|
)
|
|
351
630
|
password = password_response.role.password
|
|
352
631
|
|
|
@@ -379,30 +658,34 @@ def _create_neon_branch(
|
|
|
379
658
|
# Cleanup: delete branch unless --neon-keep-branches was specified
|
|
380
659
|
if not keep_branches:
|
|
381
660
|
try:
|
|
382
|
-
|
|
661
|
+
# Wrap in retry logic to handle rate limits
|
|
662
|
+
# See: https://api-docs.neon.tech/reference/api-rate-limiting
|
|
663
|
+
_retry_on_rate_limit(
|
|
664
|
+
lambda: neon.branch_delete(
|
|
665
|
+
project_id=project_id, branch_id=branch.id
|
|
666
|
+
),
|
|
667
|
+
operation_name="branch_delete",
|
|
668
|
+
)
|
|
383
669
|
except Exception as e:
|
|
384
670
|
# Log but don't fail tests due to cleanup issues
|
|
385
|
-
import warnings
|
|
386
|
-
|
|
387
671
|
warnings.warn(
|
|
388
672
|
f"Failed to delete Neon branch {branch.id}: {e}",
|
|
389
673
|
stacklevel=2,
|
|
390
674
|
)
|
|
391
675
|
|
|
392
676
|
|
|
393
|
-
def _reset_branch_to_parent(
|
|
394
|
-
branch: NeonBranch, api_key: str, max_retries: int = 3
|
|
395
|
-
) -> None:
|
|
677
|
+
def _reset_branch_to_parent(branch: NeonBranch, api_key: str) -> None:
|
|
396
678
|
"""Reset a branch to its parent's state using the Neon API.
|
|
397
679
|
|
|
398
|
-
Uses exponential backoff retry logic to handle
|
|
399
|
-
|
|
400
|
-
|
|
680
|
+
Uses exponential backoff retry logic with jitter to handle rate limit (429)
|
|
681
|
+
errors. After initiating the restore, polls the operation status until it
|
|
682
|
+
completes.
|
|
683
|
+
|
|
684
|
+
See: https://api-docs.neon.tech/reference/api-rate-limiting
|
|
401
685
|
|
|
402
686
|
Args:
|
|
403
687
|
branch: The branch to reset
|
|
404
688
|
api_key: Neon API key
|
|
405
|
-
max_retries: Maximum number of retry attempts (default: 3)
|
|
406
689
|
"""
|
|
407
690
|
if not branch.parent_id:
|
|
408
691
|
raise RuntimeError(f"Branch {branch.branch_id} has no parent - cannot reset")
|
|
@@ -416,41 +699,31 @@ def _reset_branch_to_parent(
|
|
|
416
699
|
"Content-Type": "application/json",
|
|
417
700
|
}
|
|
418
701
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
return # Success
|
|
445
|
-
except requests.RequestException as e:
|
|
446
|
-
last_error = e
|
|
447
|
-
if attempt < max_retries:
|
|
448
|
-
# Exponential backoff: 1s, 2s, 4s
|
|
449
|
-
wait_time = 2**attempt
|
|
450
|
-
time.sleep(wait_time)
|
|
451
|
-
|
|
452
|
-
# All retries exhausted
|
|
453
|
-
raise last_error # type: ignore[misc]
|
|
702
|
+
def do_restore() -> dict[str, Any]:
|
|
703
|
+
response = requests.post(
|
|
704
|
+
restore_url,
|
|
705
|
+
headers=headers,
|
|
706
|
+
json={"source_branch_id": branch.parent_id},
|
|
707
|
+
timeout=30,
|
|
708
|
+
)
|
|
709
|
+
response.raise_for_status()
|
|
710
|
+
return response.json()
|
|
711
|
+
|
|
712
|
+
# Wrap in retry logic to handle rate limits
|
|
713
|
+
# See: https://api-docs.neon.tech/reference/api-rate-limiting
|
|
714
|
+
data = _retry_on_rate_limit(do_restore, operation_name="branch_restore")
|
|
715
|
+
operations = data.get("operations", [])
|
|
716
|
+
|
|
717
|
+
# The restore API returns operations that run asynchronously.
|
|
718
|
+
# We must wait for operations to complete before the next test
|
|
719
|
+
# starts, otherwise connections may fail during the restore.
|
|
720
|
+
if operations:
|
|
721
|
+
_wait_for_operations(
|
|
722
|
+
project_id=branch.project_id,
|
|
723
|
+
operations=operations,
|
|
724
|
+
headers=headers,
|
|
725
|
+
base_url=base_url,
|
|
726
|
+
)
|
|
454
727
|
|
|
455
728
|
|
|
456
729
|
def _wait_for_operations(
|
|
@@ -463,6 +736,9 @@ def _wait_for_operations(
|
|
|
463
736
|
) -> None:
|
|
464
737
|
"""Wait for Neon operations to complete.
|
|
465
738
|
|
|
739
|
+
Handles rate limit (429) errors with exponential backoff retry.
|
|
740
|
+
See: https://api-docs.neon.tech/reference/api-rate-limiting
|
|
741
|
+
|
|
466
742
|
Args:
|
|
467
743
|
project_id: The Neon project ID
|
|
468
744
|
operations: List of operation dicts from the API response
|
|
@@ -496,10 +772,21 @@ def _wait_for_operations(
|
|
|
496
772
|
still_pending = []
|
|
497
773
|
for op_id in pending_op_ids:
|
|
498
774
|
op_url = f"{base_url}/projects/{project_id}/operations/{op_id}"
|
|
499
|
-
|
|
500
|
-
|
|
775
|
+
|
|
776
|
+
def get_operation_status(url: str = op_url) -> dict[str, Any]:
|
|
777
|
+
"""Fetch operation status. Default arg captures url by value."""
|
|
778
|
+
response = requests.get(url, headers=headers, timeout=10)
|
|
501
779
|
response.raise_for_status()
|
|
502
|
-
|
|
780
|
+
return response.json()
|
|
781
|
+
|
|
782
|
+
try:
|
|
783
|
+
# Wrap in retry logic to handle rate limits
|
|
784
|
+
# See: https://api-docs.neon.tech/reference/api-rate-limiting
|
|
785
|
+
result = _retry_on_rate_limit(
|
|
786
|
+
get_operation_status,
|
|
787
|
+
operation_name=f"operation_status({op_id})",
|
|
788
|
+
)
|
|
789
|
+
op_data = result.get("operation", {})
|
|
503
790
|
status = op_data.get("status")
|
|
504
791
|
|
|
505
792
|
if status == "failed":
|
|
@@ -508,7 +795,7 @@ def _wait_for_operations(
|
|
|
508
795
|
if status not in ("finished", "skipped", "cancelled"):
|
|
509
796
|
still_pending.append(op_id)
|
|
510
797
|
except requests.RequestException:
|
|
511
|
-
# On network error, assume still pending and retry
|
|
798
|
+
# On network error (non-429), assume still pending and retry
|
|
512
799
|
still_pending.append(op_id)
|
|
513
800
|
|
|
514
801
|
pending_op_ids = still_pending
|