gowalk-cicd 1.0.65 → 1.0.66
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.
- package/CLAUDE.md +3 -0
- package/README.md +10 -0
- package/action/.daemux-version +1 -1
- package/android-action/.daemux-version +1 -1
- package/android-action/scripts/play_preflight.py +28 -32
- package/android-action/scripts/play_preflight_transport.py +66 -0
- package/android-action/scripts/test_play_preflight.py +108 -0
- package/android-action/scripts/test_play_preflight_proxy_runtime.py +58 -0
- package/android-action/scripts/test_play_preflight_transport.py +76 -0
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -192,6 +192,9 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
192
192
|
delivery request. This requires no human handoff. A green CI run alone is not
|
|
193
193
|
evidence that the deferred symbols reached Firebase. Android retains its Dart
|
|
194
194
|
symbols and uploads them before the store bundle, using the configured proxy.
|
|
195
|
+
- Play readiness retries only OAuth transport and cleanup of its known edit. Never
|
|
196
|
+
replay an ambiguous `edits.insert`; error annotations carry safe phase/category
|
|
197
|
+
evidence and the edit ID when cleanup remains, without raw provider exceptions.
|
|
195
198
|
- The iOS action reads every script and prompt from the snapshot it takes of
|
|
196
199
|
itself in its first step (`$SWIFT_APP_ACTION`, under `RUNNER_TEMP`), never
|
|
197
200
|
from `${{ github.action_path }}` after that step. The plugin self-update
|
package/README.md
CHANGED
|
@@ -298,6 +298,16 @@ and prompt from that copy.
|
|
|
298
298
|
|
|
299
299
|
### Google Play upload retry
|
|
300
300
|
|
|
301
|
+
The API readiness preflight retries transient OAuth transport failures and cleanup of
|
|
302
|
+
its own known edit up to three times, with one- and two-second delays. It never
|
|
303
|
+
replays an edit creation whose outcome is unknown. Every request keeps the assigned
|
|
304
|
+
Google proxy; an absent edit after cleanup is already closed. Exhausted recovery
|
|
305
|
+
emits the `play_preflight_failed` error annotation with schema
|
|
306
|
+
`gowalk-cicd/play-preflight-failure.v1`, phase (`oauth_refresh`, `edit_create` or
|
|
307
|
+
`edit_cleanup`), classified code and attempt count. Provider bodies, credential
|
|
308
|
+
values and raw network errors are omitted. A cleanup failure also records the known
|
|
309
|
+
edit ID and `cleanup_required: true` so recovery targets that edit.
|
|
310
|
+
|
|
301
311
|
A Play edit is a short-lived server-side object, and an AAB upload that runs
|
|
302
312
|
long enough outlives one:
|
|
303
313
|
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.66
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.66
|
|
@@ -4,15 +4,16 @@
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
|
+
from functools import partial
|
|
7
8
|
import json
|
|
8
9
|
import os
|
|
9
10
|
from pathlib import Path
|
|
11
|
+
from urllib.parse import quote
|
|
10
12
|
|
|
11
13
|
import google.auth.transport.requests
|
|
12
|
-
from google.auth.exceptions import TransportError
|
|
13
|
-
import requests
|
|
14
14
|
from google.oauth2 import service_account
|
|
15
15
|
|
|
16
|
+
from play_preflight_transport import call, fail
|
|
16
17
|
from play_store_proxy import session
|
|
17
18
|
|
|
18
19
|
|
|
@@ -25,52 +26,47 @@ def write_ready(value: bool) -> None:
|
|
|
25
26
|
stream.write(f"ready={'true' if value else 'false'}\n")
|
|
26
27
|
|
|
27
28
|
|
|
28
|
-
def response_message(response: requests.Response) -> str:
|
|
29
|
-
try:
|
|
30
|
-
payload = response.json()
|
|
31
|
-
return str(payload.get("error", {}).get("message", "unknown API error"))[:500]
|
|
32
|
-
except (ValueError, AttributeError):
|
|
33
|
-
return "unknown API error"
|
|
34
|
-
|
|
35
|
-
|
|
36
29
|
def main() -> None:
|
|
37
30
|
parser = argparse.ArgumentParser()
|
|
38
31
|
parser.add_argument("--package", required=True)
|
|
39
32
|
parser.add_argument("--service-account", required=True, type=Path)
|
|
40
33
|
args = parser.parse_args()
|
|
41
34
|
|
|
42
|
-
|
|
35
|
+
with session() as client:
|
|
36
|
+
check_ready(client, args.package, args.service_account)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def check_ready(client, package: str, account: Path) -> None:
|
|
43
40
|
credentials = service_account.Credentials.from_service_account_file(
|
|
44
|
-
|
|
41
|
+
account, scopes=[SCOPE]
|
|
45
42
|
)
|
|
46
|
-
|
|
43
|
+
request = partial(google.auth.transport.requests.Request(session=client), timeout=30)
|
|
44
|
+
call("oauth_refresh", lambda: credentials.refresh(request), retry=True)
|
|
47
45
|
headers = {"Authorization": f"Bearer {credentials.token}"}
|
|
48
|
-
url = f"{BASE_URL}/applications/{
|
|
49
|
-
|
|
46
|
+
url = f"{BASE_URL}/applications/{package}/edits"
|
|
47
|
+
# A timed-out insert may already have created an edit. Never blindly insert again.
|
|
48
|
+
response = call("edit_create", lambda: client.post(url, headers=headers, json={}, timeout=30),
|
|
49
|
+
accepted=(200, 404))
|
|
50
50
|
if response.status_code == 404:
|
|
51
51
|
write_ready(False)
|
|
52
52
|
print(
|
|
53
|
-
"::warning::Google Play package is not API-ready.
|
|
54
|
-
"android-first-release artifact
|
|
55
|
-
"
|
|
53
|
+
"::warning::Google Play package is not API-ready. The app session must upload the "
|
|
54
|
+
"retained android-first-release artifact through its assigned Play Console and "
|
|
55
|
+
"verify the resulting release before retrying API delivery."
|
|
56
56
|
)
|
|
57
57
|
return
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
raise SystemExit(f"cannot close Google Play preflight edit: {message}")
|
|
58
|
+
try:
|
|
59
|
+
edit_id = json.loads(response.text)["id"]
|
|
60
|
+
if not isinstance(edit_id, str) or not edit_id or len(edit_id) > 512:
|
|
61
|
+
raise ValueError("invalid edit identifier")
|
|
62
|
+
except (ValueError, TypeError, KeyError):
|
|
63
|
+
fail("edit_create", "invalid_response", 1)
|
|
64
|
+
delete_url = f"{url}/{quote(edit_id, safe='')}"
|
|
65
|
+
call("edit_cleanup", lambda: client.delete(delete_url, headers=headers, timeout=30),
|
|
66
|
+
retry=True, edit_id=edit_id, accepted=(200, 204, 404))
|
|
68
67
|
write_ready(True)
|
|
69
68
|
print("Google Play package is ready for automated uploads")
|
|
70
69
|
|
|
71
70
|
|
|
72
71
|
if __name__ == "__main__":
|
|
73
|
-
|
|
74
|
-
main()
|
|
75
|
-
except (requests.RequestException, TransportError):
|
|
76
|
-
raise SystemExit("Google Play preflight failed through the assigned proxy") from None
|
|
72
|
+
main()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Bound safe preflight retries without replaying an ambiguous edit creation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from google.auth.exceptions import RefreshError, TransportError
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
RETRY_STATUSES = {429, 500, 502, 503, 504}
|
|
11
|
+
TRANSPORT_ERRORS = (requests.RequestException, TransportError)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def transport_code(error: Exception) -> str:
|
|
15
|
+
pending, seen = [error], set()
|
|
16
|
+
while pending and len(seen) < 12:
|
|
17
|
+
item = pending.pop(0)
|
|
18
|
+
if id(item) in seen:
|
|
19
|
+
continue
|
|
20
|
+
seen.add(id(item))
|
|
21
|
+
for kind, code in ((requests.exceptions.ProxyError, "proxy_connection_failed"),
|
|
22
|
+
(requests.exceptions.SSLError, "tls_failed"),
|
|
23
|
+
(requests.exceptions.Timeout, "timed_out"),
|
|
24
|
+
(requests.exceptions.ConnectionError, "connection_failed")):
|
|
25
|
+
if isinstance(item, kind):
|
|
26
|
+
return code
|
|
27
|
+
pending.extend(value for value in (item.__cause__, item.__context__, *item.args)
|
|
28
|
+
if isinstance(value, Exception))
|
|
29
|
+
return "transport_failed"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def fail(phase: str, code: str, attempt: int, *, status: int | None = None,
|
|
33
|
+
edit_id: str | None = None) -> None:
|
|
34
|
+
evidence = {"schema": "gowalk-cicd/play-preflight-failure.v1", "phase": phase,
|
|
35
|
+
"code": code, "attempts": attempt, "route": "assigned_proxy"}
|
|
36
|
+
if status is not None:
|
|
37
|
+
evidence["status"] = status
|
|
38
|
+
if edit_id is not None:
|
|
39
|
+
evidence["cleanup_required"] = True
|
|
40
|
+
evidence["edit_id"] = edit_id
|
|
41
|
+
encoded = json.dumps(evidence, sort_keys=True)
|
|
42
|
+
annotation = encoded.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
43
|
+
print("::error title=play_preflight_failed::" + annotation)
|
|
44
|
+
raise SystemExit("Google Play preflight failed: " + encoded) from None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def call(phase: str, operation, *, retry: bool = False, edit_id: str | None = None,
|
|
48
|
+
accepted: tuple[int, ...] | None = None):
|
|
49
|
+
limit = 3 if retry else 1
|
|
50
|
+
for attempt in range(1, limit + 1):
|
|
51
|
+
try:
|
|
52
|
+
result = operation()
|
|
53
|
+
except TRANSPORT_ERRORS as error:
|
|
54
|
+
if attempt == limit:
|
|
55
|
+
fail(phase, transport_code(error), attempt, edit_id=edit_id)
|
|
56
|
+
except RefreshError:
|
|
57
|
+
fail(phase, "oauth_refresh_failed", attempt)
|
|
58
|
+
else:
|
|
59
|
+
if not retry or getattr(result, "status_code", None) not in RETRY_STATUSES:
|
|
60
|
+
if accepted is not None and result.status_code not in accepted:
|
|
61
|
+
fail(phase, "provider_refused", attempt, status=result.status_code, edit_id=edit_id)
|
|
62
|
+
return result
|
|
63
|
+
if attempt == limit:
|
|
64
|
+
fail(phase, "provider_unavailable", attempt, status=result.status_code, edit_id=edit_id)
|
|
65
|
+
print(f"Google Play preflight retry: phase={phase} attempt={attempt + 1}/{limit}")
|
|
66
|
+
time.sleep(attempt)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Preflight retries safe operations and never duplicates an ambiguous edit insert."""
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
from unittest import mock
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
12
|
+
import play_preflight as preflight
|
|
13
|
+
import play_preflight_transport as transport
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PlayPreflightTests(unittest.TestCase):
|
|
17
|
+
def setUp(self):
|
|
18
|
+
self.client = mock.Mock()
|
|
19
|
+
self.client.post.return_value = mock.Mock(status_code=200, text='{"id":"edit-123"}')
|
|
20
|
+
self.client.delete.return_value = mock.Mock(status_code=204)
|
|
21
|
+
self.credentials = mock.Mock(token="private-token")
|
|
22
|
+
self.factory = self.enterContext(mock.patch.object(preflight.service_account.Credentials,
|
|
23
|
+
"from_service_account_file"))
|
|
24
|
+
self.factory.return_value = self.credentials
|
|
25
|
+
self.ready = self.enterContext(mock.patch.object(preflight, "write_ready"))
|
|
26
|
+
self.sleep = self.enterContext(mock.patch.object(transport.time, "sleep"))
|
|
27
|
+
self.output = self.enterContext(mock.patch("sys.stdout", new_callable=io.StringIO))
|
|
28
|
+
|
|
29
|
+
def check(self):
|
|
30
|
+
return preflight.check_ready(self.client, "com.example.app", Path("account.json"))
|
|
31
|
+
|
|
32
|
+
def test_oauth_transport_recovers_before_one_edit_is_created(self):
|
|
33
|
+
self.credentials.refresh.side_effect = [requests.exceptions.ProxyError("private-proxy"), None]
|
|
34
|
+
self.check()
|
|
35
|
+
self.assertEqual(self.credentials.refresh.call_count, 2)
|
|
36
|
+
self.client.post.assert_called_once()
|
|
37
|
+
self.client.delete.assert_called_once()
|
|
38
|
+
self.ready.assert_called_once_with(True)
|
|
39
|
+
request = self.credentials.refresh.call_args.args[0]
|
|
40
|
+
self.assertIs(request.func.session, self.client)
|
|
41
|
+
self.assertEqual(request.keywords, {"timeout": 30})
|
|
42
|
+
self.assertNotIn("private", self.output.getvalue())
|
|
43
|
+
|
|
44
|
+
def test_insert_timeout_is_not_replayed_or_reported_ready(self):
|
|
45
|
+
self.client.post.side_effect = requests.exceptions.ReadTimeout("private-token")
|
|
46
|
+
with self.assertRaises(SystemExit) as caught:
|
|
47
|
+
self.check()
|
|
48
|
+
self.assertIn('"phase": "edit_create"', str(caught.exception))
|
|
49
|
+
self.assertIn('"code": "timed_out"', str(caught.exception))
|
|
50
|
+
self.assertNotIn("private", str(caught.exception))
|
|
51
|
+
self.client.post.assert_called_once()
|
|
52
|
+
self.client.delete.assert_not_called()
|
|
53
|
+
self.ready.assert_not_called()
|
|
54
|
+
self.sleep.assert_not_called()
|
|
55
|
+
|
|
56
|
+
def test_delete_timeout_then_absence_closes_only_the_known_edit(self):
|
|
57
|
+
self.client.delete.side_effect = [requests.exceptions.ReadTimeout(), mock.Mock(status_code=404)]
|
|
58
|
+
self.check()
|
|
59
|
+
self.client.post.assert_called_once()
|
|
60
|
+
self.assertEqual(self.client.delete.call_count, 2)
|
|
61
|
+
self.assertEqual(self.client.delete.call_args_list[0], self.client.delete.call_args_list[1])
|
|
62
|
+
self.assertTrue(self.client.delete.call_args.args[0].endswith("/edit-123"))
|
|
63
|
+
self.ready.assert_called_once_with(True)
|
|
64
|
+
|
|
65
|
+
def test_cleanup_exhaustion_identifies_edit_without_ready_or_duplicate_insert(self):
|
|
66
|
+
self.client.delete.side_effect = requests.exceptions.ConnectionError("private-proxy")
|
|
67
|
+
with self.assertRaises(SystemExit) as caught:
|
|
68
|
+
self.check()
|
|
69
|
+
evidence = json.loads(str(caught.exception).split(": ", 1)[1])
|
|
70
|
+
self.assertEqual(evidence["edit_id"], "edit-123")
|
|
71
|
+
self.assertTrue(evidence["cleanup_required"])
|
|
72
|
+
self.assertEqual(evidence["attempts"], 3)
|
|
73
|
+
self.assertNotIn("private", str(caught.exception))
|
|
74
|
+
self.client.post.assert_called_once()
|
|
75
|
+
self.assertEqual(self.client.delete.call_count, 3)
|
|
76
|
+
self.ready.assert_not_called()
|
|
77
|
+
|
|
78
|
+
def test_provider_refusal_hides_untrusted_body_and_is_not_retried(self):
|
|
79
|
+
self.client.post.return_value = mock.Mock(status_code=403, text="private-access-token")
|
|
80
|
+
with self.assertRaises(SystemExit) as caught:
|
|
81
|
+
self.check()
|
|
82
|
+
self.assertIn('"status": 403', str(caught.exception))
|
|
83
|
+
self.assertNotIn("private", str(caught.exception))
|
|
84
|
+
self.client.post.assert_called_once()
|
|
85
|
+
self.ready.assert_not_called()
|
|
86
|
+
|
|
87
|
+
def test_unregistered_package_keeps_first_release_path(self):
|
|
88
|
+
self.client.post.return_value = mock.Mock(status_code=404)
|
|
89
|
+
self.check()
|
|
90
|
+
self.ready.assert_called_once_with(False)
|
|
91
|
+
self.client.delete.assert_not_called()
|
|
92
|
+
|
|
93
|
+
def test_opaque_edit_identifier_is_encoded_in_cleanup_path(self):
|
|
94
|
+
self.client.post.return_value.text = '{"id":"opaque/id?x=y"}'
|
|
95
|
+
self.check()
|
|
96
|
+
self.assertTrue(self.client.delete.call_args.args[0].endswith("/opaque%2Fid%3Fx%3Dy"))
|
|
97
|
+
|
|
98
|
+
def test_missing_proxy_stops_before_reading_credentials(self):
|
|
99
|
+
with mock.patch.dict("os.environ", {"GOOGLE_STORE_PROXY_URL": ""}), \
|
|
100
|
+
mock.patch.object(sys, "argv", ["preflight", "--package", "com.example.app",
|
|
101
|
+
"--service-account", "account.json"]):
|
|
102
|
+
with self.assertRaises(SystemExit):
|
|
103
|
+
preflight.main()
|
|
104
|
+
self.factory.assert_not_called()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
unittest.main()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Run OAuth's real requests transport against an owned rejecting loopback proxy."""
|
|
2
|
+
from contextlib import redirect_stdout
|
|
3
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
4
|
+
import io
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
from types import SimpleNamespace
|
|
10
|
+
import unittest
|
|
11
|
+
from unittest import mock
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
14
|
+
import play_preflight as preflight
|
|
15
|
+
import play_preflight_transport as transport
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RejectProxy(BaseHTTPRequestHandler):
|
|
19
|
+
def do_CONNECT(self):
|
|
20
|
+
self.server.targets.append(self.path)
|
|
21
|
+
self.send_error(502, "private-proxy-error")
|
|
22
|
+
|
|
23
|
+
def log_message(self, *args):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PreflightProxyRuntimeTests(unittest.TestCase):
|
|
28
|
+
def test_oauth_retries_only_through_assigned_proxy_without_secret_output(self):
|
|
29
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), RejectProxy)
|
|
30
|
+
server.targets = []
|
|
31
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
32
|
+
thread.start()
|
|
33
|
+
env = {"GOOGLE_STORE_PROXY_URL": f"http://user:private-password@127.0.0.1:{server.server_port}",
|
|
34
|
+
"HTTP_PROXY": "http://127.0.0.1:1", "HTTPS_PROXY": "http://127.0.0.1:1", "NO_PROXY": "*"}
|
|
35
|
+
credentials = SimpleNamespace(refresh=lambda request: request("https://oauth2.googleapis.com/token",
|
|
36
|
+
method="POST", body="private-body"))
|
|
37
|
+
output = io.StringIO()
|
|
38
|
+
try:
|
|
39
|
+
with mock.patch.dict(os.environ, env), redirect_stdout(output), \
|
|
40
|
+
mock.patch.object(preflight.service_account.Credentials, "from_service_account_file",
|
|
41
|
+
return_value=credentials), \
|
|
42
|
+
mock.patch.object(transport.time, "sleep"), \
|
|
43
|
+
mock.patch.object(sys, "argv", ["preflight", "--package", "com.example.app",
|
|
44
|
+
"--service-account", "account.json"]):
|
|
45
|
+
with self.assertRaises(SystemExit) as caught:
|
|
46
|
+
preflight.main()
|
|
47
|
+
self.assertEqual(server.targets, ["oauth2.googleapis.com:443"] * 3)
|
|
48
|
+
self.assertIn('"phase": "oauth_refresh"', str(caught.exception))
|
|
49
|
+
self.assertIn('"code": "proxy_connection_failed"', str(caught.exception))
|
|
50
|
+
self.assertNotIn("private", str(caught.exception) + output.getvalue())
|
|
51
|
+
finally:
|
|
52
|
+
server.shutdown()
|
|
53
|
+
server.server_close()
|
|
54
|
+
thread.join(timeout=2)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
unittest.main()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Classified preflight diagnostics never include raw provider/credential exceptions."""
|
|
2
|
+
import io
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sys
|
|
5
|
+
import unittest
|
|
6
|
+
from unittest import mock
|
|
7
|
+
|
|
8
|
+
from google.auth.exceptions import RefreshError, TransportError
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
12
|
+
import play_preflight_transport as transport
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PreflightTransportTests(unittest.TestCase):
|
|
16
|
+
def setUp(self):
|
|
17
|
+
self.sleep = self.enterContext(mock.patch.object(transport.time, "sleep"))
|
|
18
|
+
self.output = self.enterContext(mock.patch("sys.stdout", new_callable=io.StringIO))
|
|
19
|
+
|
|
20
|
+
def test_wrapped_oauth_transport_error_classification_does_not_read_raw_text(self):
|
|
21
|
+
for error, expected in ((requests.exceptions.ProxyError("private"), "proxy_connection_failed"),
|
|
22
|
+
(requests.exceptions.SSLError("private"), "tls_failed"),
|
|
23
|
+
(requests.exceptions.ReadTimeout("private"), "timed_out")):
|
|
24
|
+
wrapped = TransportError(error)
|
|
25
|
+
self.assertEqual(transport.transport_code(wrapped), expected)
|
|
26
|
+
|
|
27
|
+
def test_safe_operation_has_bounded_retry_and_sanitized_terminal_error(self):
|
|
28
|
+
operation = mock.Mock(side_effect=TransportError(requests.exceptions.ProxyError("private")))
|
|
29
|
+
with self.assertRaises(SystemExit) as caught:
|
|
30
|
+
transport.call("oauth_refresh", operation, retry=True)
|
|
31
|
+
self.assertEqual(operation.call_count, 3)
|
|
32
|
+
self.assertEqual(self.sleep.call_args_list, [mock.call(1), mock.call(2)])
|
|
33
|
+
self.assertIn('"code": "proxy_connection_failed"', str(caught.exception))
|
|
34
|
+
self.assertNotIn("private", str(caught.exception) + self.output.getvalue())
|
|
35
|
+
|
|
36
|
+
def test_oauth_provider_error_is_sanitized_without_an_extra_retry(self):
|
|
37
|
+
operation = mock.Mock(side_effect=RefreshError("private-provider-body"))
|
|
38
|
+
with self.assertRaises(SystemExit) as caught:
|
|
39
|
+
transport.call("oauth_refresh", operation, retry=True)
|
|
40
|
+
operation.assert_called_once()
|
|
41
|
+
self.assertIn("oauth_refresh_failed", str(caught.exception))
|
|
42
|
+
self.assertNotIn("private", str(caught.exception))
|
|
43
|
+
|
|
44
|
+
def test_cleanup_retries_server_failure_then_returns_the_success(self):
|
|
45
|
+
good = mock.Mock(status_code=204)
|
|
46
|
+
operation = mock.Mock(side_effect=[mock.Mock(status_code=503), good])
|
|
47
|
+
self.assertIs(transport.call("edit_cleanup", operation, retry=True, edit_id="123"), good)
|
|
48
|
+
self.assertEqual(operation.call_count, 2)
|
|
49
|
+
|
|
50
|
+
def test_ambiguous_server_error_on_insert_is_never_retried(self):
|
|
51
|
+
response = mock.Mock(status_code=503)
|
|
52
|
+
operation = mock.Mock(return_value=response)
|
|
53
|
+
self.assertIs(transport.call("edit_create", operation), response)
|
|
54
|
+
operation.assert_called_once()
|
|
55
|
+
self.sleep.assert_not_called()
|
|
56
|
+
|
|
57
|
+
def test_cleanup_server_failure_exhaustion_preserves_classified_evidence(self):
|
|
58
|
+
operation = mock.Mock(return_value=mock.Mock(status_code=503))
|
|
59
|
+
with self.assertRaises(SystemExit) as caught:
|
|
60
|
+
transport.call("edit_cleanup", operation, retry=True, edit_id="123")
|
|
61
|
+
self.assertIn('"code": "provider_unavailable"', str(caught.exception))
|
|
62
|
+
self.assertIn('"status": 503', str(caught.exception))
|
|
63
|
+
self.assertIn('"edit_id": "123"', str(caught.exception))
|
|
64
|
+
self.assertEqual(operation.call_count, 3)
|
|
65
|
+
|
|
66
|
+
def test_refusal_after_retry_records_the_actual_attempt_in_a_safe_annotation(self):
|
|
67
|
+
operation = mock.Mock(side_effect=[mock.Mock(status_code=503), mock.Mock(status_code=403)])
|
|
68
|
+
with self.assertRaises(SystemExit) as caught:
|
|
69
|
+
transport.call("edit_cleanup", operation, retry=True, edit_id="123", accepted=(200, 204, 404))
|
|
70
|
+
self.assertIn('"attempts": 2', str(caught.exception))
|
|
71
|
+
self.assertIn("::error title=play_preflight_failed::", self.output.getvalue())
|
|
72
|
+
self.assertIn('"cleanup_required": true', self.output.getvalue())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
unittest.main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.66
|