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.
Files changed (28) hide show
  1. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/PKG-INFO +16 -1
  2. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/README.md +15 -0
  3. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/pyproject.toml +1 -1
  4. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/src/pytest_neon/__init__.py +1 -1
  5. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/src/pytest_neon/plugin.py +349 -62
  6. pytest_neon-2.2.0/tests/test_branch_name_prefix.py +328 -0
  7. pytest_neon-2.2.0/tests/test_default_branch_safety.py +82 -0
  8. pytest_neon-2.2.0/tests/test_reset_behavior.py +581 -0
  9. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/uv.lock +1 -1
  10. pytest_neon-2.1.3/tests/test_reset_behavior.py +0 -378
  11. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/.env.example +0 -0
  12. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/.github/workflows/release.yml +0 -0
  13. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/.github/workflows/tests.yml +0 -0
  14. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/.gitignore +0 -0
  15. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/.neon +0 -0
  16. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/CLAUDE.md +0 -0
  17. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/LICENSE +0 -0
  18. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/src/pytest_neon/py.typed +0 -0
  19. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/conftest.py +0 -0
  20. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/test_branch_lifecycle.py +0 -0
  21. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/test_cli_options.py +0 -0
  22. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/test_env_var.py +0 -0
  23. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/test_fixture_errors.py +0 -0
  24. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/test_integration.py +0 -0
  25. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/test_migrations.py +0 -0
  26. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/test_readwrite_readonly_fixtures.py +0 -0
  27. {pytest_neon-2.1.3 → pytest_neon-2.2.0}/tests/test_skip_behavior.py +0 -0
  28. {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.1.3
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-neon"
7
- version = "2.1.3"
7
+ version = "2.2.0"
8
8
  description = "Pytest plugin for Neon database branch isolation in tests"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -9,7 +9,7 @@ from pytest_neon.plugin import (
9
9
  neon_engine,
10
10
  )
11
11
 
12
- __version__ = "2.1.3"
12
+ __version__ = "2.2.0"
13
13
  __all__ = [
14
14
  "NeonBranch",
15
15
  "neon_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
- branch_name = f"pytest-{os.urandom(4).hex()}{branch_name_suffix}"
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
- result = neon.branch_create(
298
- project_id=project_id,
299
- branch=branch_config,
300
- endpoints=[{"type": "read_write"}],
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
- endpoint_response = neon.endpoint(
325
- project_id=project_id, endpoint_id=endpoint_id
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
- password_response = neon.role_password_reset(
347
- project_id=project_id,
348
- branch_id=branch.id,
349
- role_name=role_name,
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
- neon.branch_delete(project_id=project_id, branch_id=branch.id)
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 transient API errors
399
- that can occur during parallel test execution. After initiating the
400
- restore, polls the operation status until it completes.
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
- last_error: Exception | None = None
420
- for attempt in range(max_retries + 1):
421
- try:
422
- response = requests.post(
423
- restore_url,
424
- headers=headers,
425
- json={"source_branch_id": branch.parent_id},
426
- timeout=30,
427
- )
428
- response.raise_for_status()
429
-
430
- # The restore API returns operations that run asynchronously.
431
- # We must wait for operations to complete before the next test
432
- # starts, otherwise connections may fail during the restore.
433
- data = response.json()
434
- operations = data.get("operations", [])
435
-
436
- if operations:
437
- _wait_for_operations(
438
- project_id=branch.project_id,
439
- operations=operations,
440
- headers=headers,
441
- base_url=base_url,
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
- try:
500
- response = requests.get(op_url, headers=headers, timeout=10)
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
- op_data = response.json().get("operation", {})
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