pytest-neon 2.1.4__py3-none-any.whl → 2.2.0__py3-none-any.whl

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/__init__.py CHANGED
@@ -9,7 +9,7 @@ from pytest_neon.plugin import (
9
9
  neon_engine,
10
10
  )
11
11
 
12
- __version__ = "2.1.4"
12
+ __version__ = "2.2.0"
13
13
  __all__ = [
14
14
  "NeonBranch",
15
15
  "neon_branch",
pytest_neon/plugin.py CHANGED
@@ -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.
@@ -166,7 +334,12 @@ def _get_default_branch_id(neon: NeonAPI, project_id: str) -> str | None:
166
334
  The branch ID of the default branch, or None if not found.
167
335
  """
168
336
  try:
169
- response = neon.branches(project_id=project_id)
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
+ )
170
343
  for branch in response.branches:
171
344
  # Check both 'default' and 'primary' flags for compatibility
172
345
  if getattr(branch, "default", False) or getattr(branch, "primary", False):
@@ -375,10 +548,15 @@ def _create_neon_branch(
375
548
  branch_config["expires_at"] = expires_at.strftime("%Y-%m-%dT%H:%M:%SZ")
376
549
 
377
550
  # Create branch with compute endpoint
378
- result = neon.branch_create(
379
- project_id=project_id,
380
- branch=branch_config,
381
- 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",
382
560
  )
383
561
 
384
562
  branch = result.branch
@@ -402,8 +580,11 @@ def _create_neon_branch(
402
580
  waited = 0.0
403
581
 
404
582
  while True:
405
- endpoint_response = neon.endpoint(
406
- 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",
407
588
  )
408
589
  endpoint = endpoint_response.endpoint
409
590
  state = endpoint.current_state
@@ -436,10 +617,15 @@ def _create_neon_branch(
436
617
 
437
618
  # Reset password to get the password value
438
619
  # (newly created branches don't expose password)
439
- password_response = neon.role_password_reset(
440
- project_id=project_id,
441
- branch_id=branch.id,
442
- 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",
443
629
  )
444
630
  password = password_response.role.password
445
631
 
@@ -472,30 +658,34 @@ def _create_neon_branch(
472
658
  # Cleanup: delete branch unless --neon-keep-branches was specified
473
659
  if not keep_branches:
474
660
  try:
475
- 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
+ )
476
669
  except Exception as e:
477
670
  # Log but don't fail tests due to cleanup issues
478
- import warnings
479
-
480
671
  warnings.warn(
481
672
  f"Failed to delete Neon branch {branch.id}: {e}",
482
673
  stacklevel=2,
483
674
  )
484
675
 
485
676
 
486
- def _reset_branch_to_parent(
487
- branch: NeonBranch, api_key: str, max_retries: int = 3
488
- ) -> None:
677
+ def _reset_branch_to_parent(branch: NeonBranch, api_key: str) -> None:
489
678
  """Reset a branch to its parent's state using the Neon API.
490
679
 
491
- Uses exponential backoff retry logic to handle transient API errors
492
- that can occur during parallel test execution. After initiating the
493
- 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
494
685
 
495
686
  Args:
496
687
  branch: The branch to reset
497
688
  api_key: Neon API key
498
- max_retries: Maximum number of retry attempts (default: 3)
499
689
  """
500
690
  if not branch.parent_id:
501
691
  raise RuntimeError(f"Branch {branch.branch_id} has no parent - cannot reset")
@@ -509,41 +699,31 @@ def _reset_branch_to_parent(
509
699
  "Content-Type": "application/json",
510
700
  }
511
701
 
512
- last_error: Exception | None = None
513
- for attempt in range(max_retries + 1):
514
- try:
515
- response = requests.post(
516
- restore_url,
517
- headers=headers,
518
- json={"source_branch_id": branch.parent_id},
519
- timeout=30,
520
- )
521
- response.raise_for_status()
522
-
523
- # The restore API returns operations that run asynchronously.
524
- # We must wait for operations to complete before the next test
525
- # starts, otherwise connections may fail during the restore.
526
- data = response.json()
527
- operations = data.get("operations", [])
528
-
529
- if operations:
530
- _wait_for_operations(
531
- project_id=branch.project_id,
532
- operations=operations,
533
- headers=headers,
534
- base_url=base_url,
535
- )
536
-
537
- return # Success
538
- except requests.RequestException as e:
539
- last_error = e
540
- if attempt < max_retries:
541
- # Exponential backoff: 1s, 2s, 4s
542
- wait_time = 2**attempt
543
- time.sleep(wait_time)
544
-
545
- # All retries exhausted
546
- 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
+ )
547
727
 
548
728
 
549
729
  def _wait_for_operations(
@@ -556,6 +736,9 @@ def _wait_for_operations(
556
736
  ) -> None:
557
737
  """Wait for Neon operations to complete.
558
738
 
739
+ Handles rate limit (429) errors with exponential backoff retry.
740
+ See: https://api-docs.neon.tech/reference/api-rate-limiting
741
+
559
742
  Args:
560
743
  project_id: The Neon project ID
561
744
  operations: List of operation dicts from the API response
@@ -589,10 +772,21 @@ def _wait_for_operations(
589
772
  still_pending = []
590
773
  for op_id in pending_op_ids:
591
774
  op_url = f"{base_url}/projects/{project_id}/operations/{op_id}"
592
- try:
593
- 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)
594
779
  response.raise_for_status()
595
- 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", {})
596
790
  status = op_data.get("status")
597
791
 
598
792
  if status == "failed":
@@ -601,7 +795,7 @@ def _wait_for_operations(
601
795
  if status not in ("finished", "skipped", "cancelled"):
602
796
  still_pending.append(op_id)
603
797
  except requests.RequestException:
604
- # On network error, assume still pending and retry
798
+ # On network error (non-429), assume still pending and retry
605
799
  still_pending.append(op_id)
606
800
 
607
801
  pending_op_ids = still_pending
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-neon
3
- Version: 2.1.4
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.
@@ -0,0 +1,8 @@
1
+ pytest_neon/__init__.py,sha256=-HXVGp38c6Ddmq90i5eo6OFxKVzBo3igJ6R3DKA8A7M,398
2
+ pytest_neon/plugin.py,sha256=8y5LYvQossHJlPpJdh0DdZj8tFcO8OY45MMMfQytcRA,49269
3
+ pytest_neon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ pytest_neon-2.2.0.dist-info/METADATA,sha256=dMgATSjnaUH1gH1posidEa99b_IE30ikeVsRsYC3oE0,19237
5
+ pytest_neon-2.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
+ pytest_neon-2.2.0.dist-info/entry_points.txt,sha256=5U88Idj_G8-PSDb9VF3OwYFbGLHnGOo_GxgYvi0dtXw,37
7
+ pytest_neon-2.2.0.dist-info/licenses/LICENSE,sha256=aKKp_Ex4WBHTByY4BhXJ181dzB_qYhi2pCUmZ7Spn_0,1067
8
+ pytest_neon-2.2.0.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- pytest_neon/__init__.py,sha256=bWWilGWaSJW2ofj0YsKqdC7r972s0c2Ar5DcE1Tn0Oc,398
2
- pytest_neon/plugin.py,sha256=A9qv1mv0CXez-SDAWruzVxjRFp0YdSWkU-8j9TWV_LA,41770
3
- pytest_neon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- pytest_neon-2.1.4.dist-info/METADATA,sha256=dmGCHSNi32pEA5wG6fZhjoFrcirza1kO16RdRSmyEJs,18734
5
- pytest_neon-2.1.4.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
- pytest_neon-2.1.4.dist-info/entry_points.txt,sha256=5U88Idj_G8-PSDb9VF3OwYFbGLHnGOo_GxgYvi0dtXw,37
7
- pytest_neon-2.1.4.dist-info/licenses/LICENSE,sha256=aKKp_Ex4WBHTByY4BhXJ181dzB_qYhi2pCUmZ7Spn_0,1067
8
- pytest_neon-2.1.4.dist-info/RECORD,,