debugbundle-python 2.0.0__tar.gz → 2.0.1__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 (61) hide show
  1. {debugbundle_python-2.0.0/src/debugbundle_python.egg-info → debugbundle_python-2.0.1}/PKG-INFO +7 -1
  2. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/README.md +6 -0
  3. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/pyproject.toml +1 -1
  4. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/acknowledgement.py +3 -1
  5. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/core.py +11 -9
  6. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/event_support.py +1 -1
  7. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/transport.py +30 -7
  8. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1/src/debugbundle_python.egg-info}/PKG-INFO +7 -1
  9. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle_python.egg-info/SOURCES.txt +1 -0
  10. debugbundle_python-2.0.1/tests/test_transport_reliability.py +144 -0
  11. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/LICENSE +0 -0
  12. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/setup.cfg +0 -0
  13. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/__init__.py +0 -0
  14. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/before_send.py +0 -0
  15. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/capture_hooks.py +0 -0
  16. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/config.py +0 -0
  17. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/delivery_hooks.py +0 -0
  18. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/exception_snapshot.py +0 -0
  19. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/flush_aggregates.py +0 -0
  20. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/integrations/__init__.py +0 -0
  21. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/integrations/common.py +0 -0
  22. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/integrations/django.py +0 -0
  23. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/integrations/fastapi.py +0 -0
  24. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/integrations/flask.py +0 -0
  25. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/integrations/relay_django.py +0 -0
  26. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/integrations/relay_fastapi.py +0 -0
  27. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/integrations/relay_flask.py +0 -0
  28. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/logger_integrations.py +0 -0
  29. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/probe_capture.py +0 -0
  30. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/process_support.py +0 -0
  31. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/py.typed +0 -0
  32. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/queue_admission.py +0 -0
  33. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/redaction.py +0 -0
  34. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/relay.py +0 -0
  35. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/relay_delivery.py +0 -0
  36. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/request_capture_policy.py +0 -0
  37. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/structlog_integration.py +0 -0
  38. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/suppression.py +0 -0
  39. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle/trigger_token.py +0 -0
  40. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle_python.egg-info/dependency_links.txt +0 -0
  41. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle_python.egg-info/requires.txt +0 -0
  42. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/src/debugbundle_python.egg-info/top_level.txt +0 -0
  43. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_before_send.py +0 -0
  44. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_contracts.py +0 -0
  45. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_delivery_hooks.py +0 -0
  46. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_exception_snapshot.py +0 -0
  47. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_framework_integrations.py +0 -0
  48. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_hooks.py +0 -0
  49. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_http_integration.py +0 -0
  50. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_optional_imports.py +0 -0
  51. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_privacy_conformance.py +0 -0
  52. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_relay.py +0 -0
  53. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_relay_delivery.py +0 -0
  54. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_remote_config.py +0 -0
  55. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_repository_metadata.py +0 -0
  56. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_schema_validation.py +0 -0
  57. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_sdk.py +0 -0
  58. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_smoke_script.py +0 -0
  59. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_structlog_filtering.py +0 -0
  60. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_suppression.py +0 -0
  61. {debugbundle_python-2.0.0 → debugbundle_python-2.0.1}/tests/test_trigger_token.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debugbundle-python
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: DebugBundle SDK for Python
5
5
  Author: DebugBundle
6
6
  License-Expression: Apache-2.0
@@ -357,3 +357,9 @@ CI validates Ruff, mypy, pytest, package build, event schema fixtures, and cover
357
357
  ## License
358
358
 
359
359
  Apache-2.0. See `LICENSE`.
360
+
361
+ ## Delivery acknowledgement and retry limits
362
+
363
+ The built-in HTTP transport requires the canonical ingestion acknowledgement (`accepted`, `rejected`, and `errors`). Empty responses, unrelated JSON, malformed counts, or invalid rejection indices retain the full batch with backoff. Valid acknowledgements remove accepted and terminally rejected events and retry only the indexed retryable rejections; an all-rejected batch does not advance `lastEventAt`.
364
+
365
+ File transports and explicitly supplied custom/legacy transports retain their documented bodyless success fallback. A custom transport that returns acknowledgement fields must return the complete canonical shape. Retry hints (delay seconds or HTTP dates) are bounded to five minutes and measured from response receipt, including partial/protocol acknowledgements and retryable server failures. Without a server hint, existing retry timing is preserved. Failures remain contained within the SDK's existing delivery path.
@@ -316,3 +316,9 @@ CI validates Ruff, mypy, pytest, package build, event schema fixtures, and cover
316
316
  ## License
317
317
 
318
318
  Apache-2.0. See `LICENSE`.
319
+
320
+ ## Delivery acknowledgement and retry limits
321
+
322
+ The built-in HTTP transport requires the canonical ingestion acknowledgement (`accepted`, `rejected`, and `errors`). Empty responses, unrelated JSON, malformed counts, or invalid rejection indices retain the full batch with backoff. Valid acknowledgements remove accepted and terminally rejected events and retry only the indexed retryable rejections; an all-rejected batch does not advance `lastEventAt`.
323
+
324
+ File transports and explicitly supplied custom/legacy transports retain their documented bodyless success fallback. A custom transport that returns acknowledgement fields must return the complete canonical shape. Retry hints (delay seconds or HTTP dates) are bounded to five minutes and measured from response receipt, including partial/protocol acknowledgements and retryable server failures. Without a server hint, existing retry timing is preserved. Failures remain contained within the SDK's existing delivery path.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "debugbundle-python"
7
- version = "2.0.0"
7
+ version = "2.0.1"
8
8
  description = "DebugBundle SDK for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -19,8 +19,10 @@ class AcknowledgementDecision:
19
19
  reason: str | None = None
20
20
 
21
21
 
22
- def decide_acknowledgement(body: object | None, batch_length: int) -> AcknowledgementDecision:
22
+ def decide_acknowledgement(body: object | None, batch_length: int, required: bool = False) -> AcknowledgementDecision:
23
23
  if not isinstance(body, dict) or not any(key in body for key in ("accepted", "rejected", "errors")):
24
+ if required:
25
+ return AcknowledgementDecision(kind="protocol_failure", reason="missing_acknowledgement")
24
26
  return AcknowledgementDecision(kind="legacy")
25
27
 
26
28
  accepted = body.get("accepted")
@@ -61,7 +61,7 @@ from .redaction import (
61
61
  )
62
62
  from .request_capture_policy import should_capture_request_event
63
63
  from .suppression import EventSuppressionTracker
64
- from .transport import HttpTransport, Transport, coerce_transport_response
64
+ from .transport import HttpTransport, Transport, bounded_retry_after_ms, coerce_transport_response
65
65
  from .trigger_token import resolve_request_trigger_directives
66
66
 
67
67
  DEFAULT_BATCH_SIZE = 25
@@ -499,11 +499,13 @@ class DebugBundleSdk:
499
499
  return
500
500
 
501
501
  if 200 <= response.status_code < 300:
502
- acknowledgement = decide_acknowledgement(response.body, len(batch))
502
+ acknowledgement = decide_acknowledgement(
503
+ response.body, len(batch), isinstance(transport, HttpTransport)
504
+ )
503
505
  if acknowledgement.kind == "protocol_failure":
504
506
  self._consecutive_failures += 1
505
- retry_after_ms = response.retry_after_ms if response.retry_after_ms is not None else 1_000
506
- self._retry_after = now + (retry_after_ms / 1000)
507
+ retry_after_ms = bounded_retry_after_ms(response.retry_after_ms)
508
+ self._retry_after = self._time_provider() + (retry_after_ms / 1000)
507
509
  self._emit_diagnostic(
508
510
  "ingestion_acknowledgement_invalid",
509
511
  "sdk-python retained a batch after an invalid ingestion acknowledgement",
@@ -535,8 +537,8 @@ class DebugBundleSdk:
535
537
  self._last_event_at = self._time_provider() * 1000
536
538
  if acknowledgement.retryable_indices:
537
539
  self._consecutive_failures += 1
538
- retry_after_ms = response.retry_after_ms if response.retry_after_ms is not None else 1_000
539
- self._retry_after = now + (retry_after_ms / 1000)
540
+ retry_after_ms = bounded_retry_after_ms(response.retry_after_ms)
541
+ self._retry_after = self._time_provider() + (retry_after_ms / 1000)
540
542
  self._schedule_flush_locked(delay=retry_after_ms / 1000)
541
543
  return
542
544
  self._retry_after = 0.0
@@ -544,9 +546,9 @@ class DebugBundleSdk:
544
546
  return
545
547
 
546
548
  self._consecutive_failures += 1
547
- if response.status_code == 429:
548
- retry_after_ms = response.retry_after_ms if response.retry_after_ms is not None else 1_000
549
- self._retry_after = now + (retry_after_ms / 1000)
549
+ if response.status_code == 429 or (response.status_code >= 500 and response.retry_after_ms is not None):
550
+ retry_after_ms = bounded_retry_after_ms(response.retry_after_ms)
551
+ self._retry_after = self._time_provider() + (retry_after_ms / 1000)
550
552
  self._schedule_flush_locked(delay=retry_after_ms / 1000)
551
553
  return
552
554
  if 400 <= response.status_code < 500:
@@ -264,7 +264,7 @@ def sdk_version() -> str:
264
264
  try:
265
265
  return metadata.version("debugbundle-python")
266
266
  except metadata.PackageNotFoundError:
267
- return "2.0.0"
267
+ return "2.0.1"
268
268
 
269
269
 
270
270
  def sdk_config_endpoint(events_endpoint: str) -> str:
@@ -1,11 +1,40 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import math
4
+ import time
3
5
  from collections.abc import Mapping
4
6
  from dataclasses import dataclass
7
+ from email.utils import parsedate_to_datetime
5
8
  from typing import Any, Protocol
6
9
 
7
10
  import httpx
8
11
 
12
+ MAX_RETRY_AFTER_MS = 300_000
13
+
14
+
15
+ def bounded_retry_after_ms(value: object, default: int = 1_000) -> int:
16
+ # Clamp before conversion so arbitrarily large integer hints cannot overflow a float deadline.
17
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
18
+ return default
19
+ if isinstance(value, float) and not math.isfinite(value):
20
+ return default
21
+ return int(min(MAX_RETRY_AFTER_MS, max(0, value)))
22
+
23
+
24
+ def _parse_retry_after(value: str | None) -> int | None:
25
+ if value is None:
26
+ return None
27
+ try:
28
+ seconds = float(value)
29
+ except ValueError:
30
+ try:
31
+ seconds = parsedate_to_datetime(value).timestamp() - time.time()
32
+ except (ValueError, TypeError, OverflowError):
33
+ return None
34
+ if not math.isfinite(seconds):
35
+ return None
36
+ return bounded_retry_after_ms(min(300, max(0, seconds)) * 1_000)
37
+
9
38
 
10
39
  @dataclass
11
40
  class TransportResponse:
@@ -34,13 +63,7 @@ class HttpTransport:
34
63
  json={"events": request["events"]},
35
64
  headers=headers,
36
65
  )
37
- retry_after_header = response.headers.get("retry-after")
38
- retry_after_ms = None
39
- if retry_after_header is not None:
40
- try:
41
- retry_after_ms = int(float(retry_after_header) * 1000)
42
- except ValueError:
43
- retry_after_ms = None
66
+ retry_after_ms = _parse_retry_after(response.headers.get("retry-after"))
44
67
 
45
68
  try:
46
69
  body: object | None = response.json()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debugbundle-python
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: DebugBundle SDK for Python
5
5
  Author: DebugBundle
6
6
  License-Expression: Apache-2.0
@@ -357,3 +357,9 @@ CI validates Ruff, mypy, pytest, package build, event schema fixtures, and cover
357
357
  ## License
358
358
 
359
359
  Apache-2.0. See `LICENSE`.
360
+
361
+ ## Delivery acknowledgement and retry limits
362
+
363
+ The built-in HTTP transport requires the canonical ingestion acknowledgement (`accepted`, `rejected`, and `errors`). Empty responses, unrelated JSON, malformed counts, or invalid rejection indices retain the full batch with backoff. Valid acknowledgements remove accepted and terminally rejected events and retry only the indexed retryable rejections; an all-rejected batch does not advance `lastEventAt`.
364
+
365
+ File transports and explicitly supplied custom/legacy transports retain their documented bodyless success fallback. A custom transport that returns acknowledgement fields must return the complete canonical shape. Retry hints (delay seconds or HTTP dates) are bounded to five minutes and measured from response receipt, including partial/protocol acknowledgements and retryable server failures. Without a server hint, existing retry timing is preserved. Failures remain contained within the SDK's existing delivery path.
@@ -55,4 +55,5 @@ tests/test_sdk.py
55
55
  tests/test_smoke_script.py
56
56
  tests/test_structlog_filtering.py
57
57
  tests/test_suppression.py
58
+ tests/test_transport_reliability.py
58
59
  tests/test_trigger_token.py
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+ from email.utils import format_datetime
5
+ from types import SimpleNamespace
6
+
7
+ import httpx
8
+ import pytest
9
+
10
+ from debugbundle.core import DebugBundleSdk
11
+ from debugbundle.transport import HttpTransport, TransportResponse
12
+
13
+
14
+ @pytest.mark.parametrize("hint", [float("nan"), float("inf"), "1e100", True])
15
+ def test_invalid_custom_retry_hint_uses_safe_backoff_without_losing_events(hint: object) -> None:
16
+ now = 1_700_000_000.0
17
+ sdk = DebugBundleSdk(
18
+ transport=lambda _: SimpleNamespace(status_code=429, retry_after_ms=hint), time_provider=lambda: now
19
+ )
20
+ sdk.init(project_token="dbundle_proj_test", batch_size=100, flush_interval=3600)
21
+ try:
22
+ sdk.capture_message("retain", level="error")
23
+ sdk.flush()
24
+ assert sdk._retry_after == now + 1
25
+ assert len(sdk._buffer) == 1
26
+ assert sdk.last_event_at is None
27
+ finally:
28
+ sdk.dispose()
29
+
30
+
31
+ @pytest.mark.parametrize(
32
+ "body",
33
+ [
34
+ "",
35
+ '{"accepted":null,"rejected":2,"errors":[{"index":0,"reason":"rate_limited"},{"index":1,"reason":"rate_limited"}]}',
36
+ '{"accepted":2,"rejected":0,"errors":null}',
37
+ '{"accepted":1,"rejected":1,"errors":[{"index":4294967296,"reason":"rate_limited"}]}',
38
+ '{"accepted":1,"rejected":1,"errors":{"one":{"index":1,"reason":"rate_limited"}}}',
39
+ "<html>proxy</html>",
40
+ "{}",
41
+ "[]",
42
+ "null",
43
+ '{"accepted":1,"rejected":0,"errors":[]}',
44
+ '{"accepted":0,"rejected":2,"errors":[{"index":0,"reason":"rate_limited"},{"index":0,"reason":"rate_limited"}]}',
45
+ '{"accepted":1,"rejected":1,"errors":[{"index":2,"reason":"rate_limited"}]}',
46
+ ],
47
+ )
48
+ def test_builtin_http_retains_unacknowledged_batch_and_recovers(body: str) -> None:
49
+ batches: list[object] = []
50
+
51
+ def respond(request: httpx.Request) -> httpx.Response:
52
+ import json
53
+
54
+ batches.append(json.loads(request.content)["events"])
55
+ return httpx.Response(
56
+ 202,
57
+ text=body if len(batches) == 1 else '{"accepted":2,"rejected":0,"errors":[]}',
58
+ headers={"Retry-After": "300"},
59
+ )
60
+
61
+ transport = HttpTransport("https://example.invalid/events")
62
+ transport._client.close()
63
+ transport._client = httpx.Client(transport=httpx.MockTransport(respond))
64
+ now = [1_700_000_000.0]
65
+ sdk = DebugBundleSdk(transport=transport, time_provider=lambda: now[0])
66
+ sdk.init(project_token="dbundle_proj_test", batch_size=100, flush_interval=3600)
67
+ try:
68
+ sdk.capture_message("first", level="error")
69
+ sdk.capture_message("second", level="error")
70
+ sdk.flush()
71
+ assert sdk.last_event_at is None
72
+ assert len(sdk._buffer) == 2
73
+ sdk.flush()
74
+ assert len(batches) == 1
75
+ now[0] += 301
76
+ sdk.flush()
77
+ assert len(batches) == 2 and batches[0] == batches[1]
78
+ assert sdk.last_event_at is not None
79
+ finally:
80
+ sdk.dispose()
81
+ transport.close()
82
+
83
+
84
+ @pytest.mark.parametrize(
85
+ "header,expected",
86
+ [
87
+ ("0.25", 250),
88
+ ("999999", 300_000),
89
+ ("1e300", 300_000),
90
+ ("-1", 0),
91
+ ("NaN", None),
92
+ ("Infinity", None),
93
+ ("nope", None),
94
+ (format_datetime(datetime.now(timezone.utc) + timedelta(days=1), usegmt=True), 300_000),
95
+ ("Sun, 06 Nov 1994 08:49:37 GMT", 0),
96
+ ],
97
+ )
98
+ def test_http_retry_after_is_bounded(header: str, expected: int | None) -> None:
99
+ transport = HttpTransport("https://example.invalid/events")
100
+ transport._client.close()
101
+ transport._client = httpx.Client(
102
+ transport=httpx.MockTransport(lambda _: httpx.Response(429, headers={"Retry-After": header}))
103
+ )
104
+ try:
105
+ assert transport({"project_token": "dbundle_proj_test", "events": []}).retry_after_ms == expected
106
+ finally:
107
+ transport.close()
108
+
109
+
110
+ @pytest.mark.parametrize(
111
+ "body,status",
112
+ [
113
+ (None, 429),
114
+ (None, 503),
115
+ ({}, 202),
116
+ ({"accepted": 0, "rejected": 1, "errors": [{"index": 0, "reason": "rate_limited"}]}, 202),
117
+ ],
118
+ )
119
+ def test_custom_retry_hint_cannot_suppress_delivery_beyond_five_minutes(body: object, status: int) -> None:
120
+ calls: list[object] = []
121
+ now = [1_700_000_000.0]
122
+
123
+ def send(request: object) -> TransportResponse:
124
+ calls.append(request)
125
+ now[0] += 10 # The delay starts when the response arrives, not when sending began.
126
+ return TransportResponse(status, 10**100, body)
127
+
128
+ # An empty custom ACK is explicitly legacy-compatible; use malformed counts for protocol failure.
129
+ if body == {}:
130
+ body = {"accepted": 2, "rejected": 0, "errors": []}
131
+ sdk = DebugBundleSdk(transport=send, time_provider=lambda: now[0])
132
+ sdk.init(project_token="dbundle_proj_test", batch_size=100, flush_interval=3600)
133
+ try:
134
+ sdk.capture_message("retry", level="error")
135
+ sdk.flush()
136
+ assert sdk._retry_after == now[0] + 300
137
+ now[0] += 299
138
+ sdk.flush()
139
+ assert len(calls) == 1
140
+ now[0] += 2
141
+ sdk.flush()
142
+ assert len(calls) == 2
143
+ finally:
144
+ sdk.dispose()