claude-multiacc 2.0.23 → 2.0.25
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/README.md +12 -0
- package/bin/codex-accounts +4 -2
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/codex_reset.py +8 -7
- package/lib/codex_reset_telemetry.py +34 -0
- package/lib/report.py +4 -0
- package/package.json +1 -1
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/test_codex_reset.py +6 -2
- package/tests/test_codex_reset_polling.py +94 -0
- package/tests/test_codex_reset_reporting.py +82 -0
package/README.md
CHANGED
|
@@ -380,6 +380,18 @@ Codex-specific notes:
|
|
|
380
380
|
`CODEX_MULTIACC_AUTO_RESET=0` only for emergency rollback/testing.
|
|
381
381
|
A finished-limit verdict takes precedence over a conflicting percentage. The
|
|
382
382
|
redemption key excludes unused model windows whose reset clocks move between reads.
|
|
383
|
+
Eligible accounts, including spent ones, check the credit endpoint every five
|
|
384
|
+
minutes even if the usage summary still says zero credits. A newly granted reset
|
|
385
|
+
is redeemed during that pass and clears the old limit; no-credit results are
|
|
386
|
+
checked again next pass, without waiting for the weekly reset date.
|
|
387
|
+
All authenticated accounts also report `usage.reset_credits_available` and
|
|
388
|
+
`usage.reset_credits_fetched_at` (ISO 8601) in `list/status/limits --json`.
|
|
389
|
+
The count comes from the credit endpoint, including below 95% used and with
|
|
390
|
+
automatic redemption disabled. The scheduled usage cadence bounds these reads.
|
|
391
|
+
Redemption shares that read, then reads back the remaining count after a POST;
|
|
392
|
+
no subtraction or embedded usage summary substitutes for provider evidence.
|
|
393
|
+
Zero means no available resets; missing fields mean the read was unavailable.
|
|
394
|
+
Credit identifiers remain local and selection does not use these fields.
|
|
383
395
|
- **API-key logins are rejected** — ChatGPT subscription accounts only, matching the
|
|
384
396
|
addon's no-API-keys rule.
|
|
385
397
|
|
package/bin/codex-accounts
CHANGED
|
@@ -1068,7 +1068,7 @@ import base64, datetime, json, os, sys, time, urllib.request
|
|
|
1068
1068
|
root, threshold, quiet, url, force, lib_dir = (sys.argv[1], int(sys.argv[2]),
|
|
1069
1069
|
sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1', sys.argv[6])
|
|
1070
1070
|
sys.path.insert(0, lib_dir)
|
|
1071
|
-
from
|
|
1071
|
+
from codex_reset_telemetry import refresh_reset_credits
|
|
1072
1072
|
now = time.time()
|
|
1073
1073
|
# Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
|
|
1074
1074
|
MIN_FETCH_INTERVAL = int(os.environ.get('CODEX_MULTIACC_MIN_FETCH', '240'))
|
|
@@ -1564,15 +1564,17 @@ for acct in manifest.get('accounts', []):
|
|
|
1564
1564
|
weeklyp = max(weekly) if weekly else None
|
|
1565
1565
|
sessionp = max(session) if session else None
|
|
1566
1566
|
try:
|
|
1567
|
-
reset_result =
|
|
1567
|
+
reset_result, reset_view = refresh_reset_credits(d, aid, maxp, data, url, headers, say, int(now))
|
|
1568
1568
|
except Exception as e:
|
|
1569
1569
|
say(f'{aid}: usage reset automation failed unexpectedly ({type(e).__name__}); failing open')
|
|
1570
1570
|
reset_result = {'status': 'error'}
|
|
1571
|
+
reset_view = {}
|
|
1571
1572
|
# Internal only (see win_bucket): limits.json keeps the bucket shape every reader
|
|
1572
1573
|
# — the shim, lib/report.py, app-robot — already knows.
|
|
1573
1574
|
for b in buckets:
|
|
1574
1575
|
b.pop('_reset_known', None)
|
|
1575
1576
|
out = {'fetched_at': int(now), 'source': 'chatgpt'}
|
|
1577
|
+
out.update(reset_view)
|
|
1576
1578
|
if live:
|
|
1577
1579
|
out['max_percent'] = maxp
|
|
1578
1580
|
if weeklyp is not None:
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/codex_reset.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""Automatic redemption of earned Codex usage-limit reset credits.
|
|
2
2
|
|
|
3
3
|
The Codex CLI exposes reset credits through the same authenticated backend as
|
|
4
|
-
usage telemetry.
|
|
5
|
-
|
|
4
|
+
usage telemetry. Each scheduled eligible usage pass reads the credit endpoint,
|
|
5
|
+
even when the usage response's embedded credit summary is empty or out of date.
|
|
6
|
+
A pending idempotency key is written
|
|
6
7
|
before the mutation so a lost response can be retried without spending twice.
|
|
7
8
|
"""
|
|
8
9
|
|
|
@@ -110,7 +111,7 @@ def _redeem_request_id(account_id: str, usage: dict, headers: dict, now: int) ->
|
|
|
110
111
|
|
|
111
112
|
def try_auto_redeem(account_dir: str, account_id: str, used_percent: int,
|
|
112
113
|
usage: dict, usage_url: str, headers: dict, say,
|
|
113
|
-
now: int | None = None) -> dict:
|
|
114
|
+
now: int | None = None, credit_details: dict | None = None) -> dict:
|
|
114
115
|
"""Redeem one available reset and return a non-secret outcome document."""
|
|
115
116
|
current = int(now if now is not None else time.time())
|
|
116
117
|
finished, _ = reset_windows(usage, RESET_AT_USED_PERCENT)
|
|
@@ -121,11 +122,11 @@ def try_auto_redeem(account_dir: str, account_id: str, used_percent: int,
|
|
|
121
122
|
return {"status": "cooldown", "outcome": state.get("outcome")}
|
|
122
123
|
list_url, consume_url = _reset_urls(usage_url)
|
|
123
124
|
if disposition == "new":
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return {"status": "no_credit"}
|
|
125
|
+
# Newly granted credits can precede the usage response's embedded summary.
|
|
126
|
+
# The scheduled usage cadence bounds these reads, including spent accounts.
|
|
127
127
|
try:
|
|
128
|
-
|
|
128
|
+
details = credit_details if credit_details is not None else _request(list_url, headers)
|
|
129
|
+
available_count, credit_id = _credit_to_redeem(details)
|
|
129
130
|
except Exception as error:
|
|
130
131
|
say(f"{account_id}: usage reset availability failed ({type(error).__name__}); failing open")
|
|
131
132
|
return {"status": "error"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Observe reset allowances at the usage cadence, independently of redemption eligibility."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from codex_reset import _request, _reset_urls, try_auto_redeem
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _read_credits(url, headers, account_id, say):
|
|
9
|
+
try:
|
|
10
|
+
details = _request(url, headers)
|
|
11
|
+
count = details.get("available_count")
|
|
12
|
+
if type(count) is not int or not 0 <= count <= 1_000_000:
|
|
13
|
+
raise ValueError("invalid reset allowance")
|
|
14
|
+
return details, {"reset_credits_available": count,
|
|
15
|
+
"reset_credits_fetched_at": int(time.time())}
|
|
16
|
+
except Exception as error:
|
|
17
|
+
say(f"{account_id}: usage reset availability failed ({type(error).__name__})")
|
|
18
|
+
return {}, {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def refresh_reset_credits(account_dir, account_id, used_percent, usage, usage_url, headers, say, now):
|
|
22
|
+
"""Read all accounts, share the GET with redemption, then read back after a mutation.
|
|
23
|
+
|
|
24
|
+
Failed reads omit the count; neither an embedded summary nor subtraction proves
|
|
25
|
+
the remaining allowance after another machine may have redeemed a credit.
|
|
26
|
+
Disabling automatic redemption still permits this read-only telemetry.
|
|
27
|
+
"""
|
|
28
|
+
list_url, _ = _reset_urls(usage_url)
|
|
29
|
+
details, view = _read_credits(list_url, headers, account_id, say)
|
|
30
|
+
result = try_auto_redeem(account_dir, account_id, used_percent, usage, usage_url,
|
|
31
|
+
headers, say, now, credit_details=details)
|
|
32
|
+
if result.get("status") not in {"not_eligible", "cooldown", "no_credit", "error"}:
|
|
33
|
+
_, view = _read_credits(list_url, headers, account_id, say)
|
|
34
|
+
return result, view
|
package/lib/report.py
CHANGED
|
@@ -242,6 +242,10 @@ def _usage(d, stale_after):
|
|
|
242
242
|
out = {'fetched_at': _iso(fetched), 'source': lim.get('source'),
|
|
243
243
|
'max_percent': lim.get('max_percent'), 'weekly_percent': lim.get('weekly_percent'),
|
|
244
244
|
'session_percent': lim.get('session_percent'), 'buckets': buckets}
|
|
245
|
+
reset_count = lim.get('reset_credits_available')
|
|
246
|
+
reset_stamp = _iso(lim.get('reset_credits_fetched_at'))
|
|
247
|
+
if type(reset_count) is int and 0 <= reset_count <= 1_000_000 and reset_stamp:
|
|
248
|
+
out.update(reset_credits_available=reset_count, reset_credits_fetched_at=reset_stamp)
|
|
245
249
|
try:
|
|
246
250
|
out['age_seconds'] = max(0, int(time.time() - float(fetched)))
|
|
247
251
|
except (TypeError, ValueError):
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -50,6 +50,10 @@ class ResetHandler(BaseHTTPRequestHandler):
|
|
|
50
50
|
length = int(self.headers.get("Content-Length") or 0)
|
|
51
51
|
state["posts"].append(json.loads(self.rfile.read(length)))
|
|
52
52
|
status, body = state["responses"].pop(0)
|
|
53
|
+
if status == 200 and body.get("code") == "reset":
|
|
54
|
+
state["credits"]["available_count"] -= 1
|
|
55
|
+
if "credits_after_post" in state:
|
|
56
|
+
state["credits"] = state["credits_after_post"]
|
|
53
57
|
encoded = json.dumps(body).encode()
|
|
54
58
|
self.send_response(status)
|
|
55
59
|
self.send_header("Content-Type", "application/json")
|
|
@@ -144,7 +148,7 @@ class CodexResetIntegrationTest(CodexPoolSandbox, unittest.TestCase):
|
|
|
144
148
|
"rate_limit_reset_credits": {"available_count": 1}}
|
|
145
149
|
result = self.run_limits()
|
|
146
150
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
147
|
-
self.assertEqual(self.server.state["credit_gets"],
|
|
151
|
+
self.assertEqual(self.server.state["credit_gets"], 1)
|
|
148
152
|
self.assertEqual(self.server.state["posts"], [])
|
|
149
153
|
self.assertFalse((self.pool / "acct-01/.usage-reset.json").exists())
|
|
150
154
|
|
|
@@ -167,7 +171,7 @@ class CodexResetIntegrationTest(CodexPoolSandbox, unittest.TestCase):
|
|
|
167
171
|
self.assertEqual(len(self.server.state["posts"]), 2)
|
|
168
172
|
keys = [request["redeem_request_id"] for request in self.server.state["posts"]]
|
|
169
173
|
self.assertEqual(keys[0], keys[1])
|
|
170
|
-
self.assertEqual(self.server.state["credit_gets"],
|
|
174
|
+
self.assertEqual(self.server.state["credit_gets"], 4)
|
|
171
175
|
|
|
172
176
|
|
|
173
177
|
class CodexMarkerNamesOneBucketTest(CodexPoolSandbox, unittest.TestCase):
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Scheduled passes discover newly granted resets despite an old usage summary."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
import test_codex_reset as fixtures
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ResetPollingTest(fixtures.CodexPoolSandbox, unittest.TestCase):
|
|
13
|
+
def setUp(self):
|
|
14
|
+
super().setUp()
|
|
15
|
+
now = int(time.time())
|
|
16
|
+
self.server.state["usage"] = {"rate_limit": {"allowed": False, "limit_reached": True,
|
|
17
|
+
"primary_window": {"used_percent": 100, "limit_window_seconds": 604800,
|
|
18
|
+
"reset_at": now + 6 * 86400}},
|
|
19
|
+
"rate_limit_reset_credits": {"available_count": 0}}
|
|
20
|
+
self.server.state["credits"] = {"available_count": 0, "credits": []}
|
|
21
|
+
|
|
22
|
+
def run_scheduled(self, enabled="1"):
|
|
23
|
+
base = f"http://127.0.0.1:{self.server.server_port}/wham"
|
|
24
|
+
env = dict(os.environ, CODEX_ACCOUNTS_ROOT=str(self.pool), CODEX_MULTIACC_NO_SYNC="1",
|
|
25
|
+
CODEX_MULTIACC_AUTO_RESET=enabled, CODEX_MULTIACC_USAGE_URL=f"{base}/usage",
|
|
26
|
+
PYTHONDONTWRITEBYTECODE="1")
|
|
27
|
+
for key in ("CODEX_MULTIACC_MIN_FETCH", "CODEX_MULTIACC_RESET_CREDITS_URL",
|
|
28
|
+
"CODEX_MULTIACC_RESET_CONSUME_URL"):
|
|
29
|
+
env.pop(key, None)
|
|
30
|
+
result = subprocess.run([fixtures.REPO / "bin/codex-accounts", "limits", "--quiet"],
|
|
31
|
+
capture_output=True, text=True, env=env, timeout=20, check=False)
|
|
32
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
33
|
+
|
|
34
|
+
def next_pass(self):
|
|
35
|
+
path = self.pool / "acct-01/limits.json"
|
|
36
|
+
usage = json.loads(path.read_text())
|
|
37
|
+
usage["fetched_at"] = int(time.time()) - 301
|
|
38
|
+
path.write_text(json.dumps(usage))
|
|
39
|
+
|
|
40
|
+
def grant_credit(self):
|
|
41
|
+
self.server.state["credits"] = {"available_count": 1, "credits": [
|
|
42
|
+
{"id": "newly-granted", "status": "available", "expires_at": "2099-01-01T00:00:00Z"}]}
|
|
43
|
+
self.server.state["responses"] = [(200, {"code": "reset", "windows_reset": 1})]
|
|
44
|
+
|
|
45
|
+
def test_limited_account_polls_again_and_uses_a_new_credit(self):
|
|
46
|
+
self.run_scheduled()
|
|
47
|
+
self.assertEqual(self.server.state["credit_gets"], 1)
|
|
48
|
+
marker = self.pool / "acct-01/.limited"
|
|
49
|
+
self.assertTrue(marker.exists())
|
|
50
|
+
self.assertEqual(self.server.state["posts"], [])
|
|
51
|
+
self.run_scheduled()
|
|
52
|
+
self.assertEqual(self.server.state["credit_gets"], 1) # Normal fetch cadence still applies.
|
|
53
|
+
self.next_pass()
|
|
54
|
+
self.run_scheduled()
|
|
55
|
+
self.assertEqual(self.server.state["credit_gets"], 2)
|
|
56
|
+
self.assertTrue(marker.exists())
|
|
57
|
+
self.grant_credit()
|
|
58
|
+
self.next_pass()
|
|
59
|
+
self.run_scheduled()
|
|
60
|
+
self.assertEqual(self.server.state["credit_gets"], 4)
|
|
61
|
+
self.assertEqual(self.server.state["posts"][0]["credit_id"], "newly-granted")
|
|
62
|
+
self.assertFalse(marker.exists())
|
|
63
|
+
receipt = json.loads((self.pool / "acct-01/.usage-reset.json").read_text())
|
|
64
|
+
self.assertEqual(receipt["state"], "complete")
|
|
65
|
+
usage = json.loads((self.pool / "acct-01/limits.json").read_text())
|
|
66
|
+
self.assertEqual(usage["fetched_at"], 0) # Pre-reset exhaustion cannot exclude it again.
|
|
67
|
+
|
|
68
|
+
def test_malformed_usage_summary_does_not_hide_a_credit(self):
|
|
69
|
+
self.server.state["usage"]["rate_limit_reset_credits"]["available_count"] = "unknown"
|
|
70
|
+
self.grant_credit()
|
|
71
|
+
self.run_scheduled()
|
|
72
|
+
self.assertEqual(len(self.server.state["posts"]), 1)
|
|
73
|
+
|
|
74
|
+
def test_credit_lookup_failure_preserves_limit_and_retries_next_pass(self):
|
|
75
|
+
self.server.state["credits"] = [] # An unreadable credit response is not a reset.
|
|
76
|
+
self.run_scheduled()
|
|
77
|
+
self.assertTrue((self.pool / "acct-01/.limited").exists())
|
|
78
|
+
self.assertEqual(self.server.state["posts"], [])
|
|
79
|
+
self.grant_credit()
|
|
80
|
+
self.next_pass()
|
|
81
|
+
self.run_scheduled()
|
|
82
|
+
self.assertEqual(self.server.state["credit_gets"], 3)
|
|
83
|
+
self.assertFalse((self.pool / "acct-01/.limited").exists())
|
|
84
|
+
|
|
85
|
+
def test_disabled_automation_reports_without_spending_credits(self):
|
|
86
|
+
self.grant_credit()
|
|
87
|
+
self.run_scheduled(enabled="0")
|
|
88
|
+
self.assertEqual(self.server.state["credit_gets"], 1)
|
|
89
|
+
self.assertEqual(self.server.state["posts"], [])
|
|
90
|
+
self.assertTrue((self.pool / "acct-01/.limited").exists())
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
unittest.main()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""The public pool report carries measured allowances for active and limited accounts."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
import test_codex_reset as fixtures
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ResetReportingTest(fixtures.CodexPoolSandbox, unittest.TestCase):
|
|
13
|
+
def report(self, used=60, enabled="1"):
|
|
14
|
+
self.server.state["usage"] = {"rate_limit": {"allowed": True,
|
|
15
|
+
"primary_window": {"used_percent": used, "limit_window_seconds": 604800,
|
|
16
|
+
"reset_at": int(time.time()) + 86400}},
|
|
17
|
+
"rate_limit_reset_credits": {"available_count": 0}}
|
|
18
|
+
base = f"http://127.0.0.1:{self.server.server_port}/wham"
|
|
19
|
+
env = dict(os.environ, CODEX_ACCOUNTS_ROOT=str(self.pool), CODEX_MULTIACC_NO_SYNC="1",
|
|
20
|
+
CODEX_MULTIACC_AUTO_RESET=enabled, CODEX_MULTIACC_USAGE_URL=f"{base}/usage",
|
|
21
|
+
PYTHONDONTWRITEBYTECODE="1")
|
|
22
|
+
for key in ("CODEX_MULTIACC_RESET_CREDITS_URL", "CODEX_MULTIACC_RESET_CONSUME_URL"):
|
|
23
|
+
env.pop(key, None)
|
|
24
|
+
result = subprocess.run([fixtures.REPO / "bin/codex-accounts", "limits", "--force", "--json"],
|
|
25
|
+
capture_output=True, text=True, env=env, timeout=20, check=False)
|
|
26
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
27
|
+
self.assertNotIn("private-credit", result.stdout)
|
|
28
|
+
return json.loads(result.stdout)["accounts"][0]
|
|
29
|
+
|
|
30
|
+
def test_active_account_reports_credits_without_redeeming(self):
|
|
31
|
+
self.server.state["credits"] = {"available_count": 3, "credits": [
|
|
32
|
+
{"id": "private-credit", "status": "available"}]}
|
|
33
|
+
account = self.report()
|
|
34
|
+
self.assertEqual(account["usage"]["reset_credits_available"], 3)
|
|
35
|
+
self.assertTrue(account["usage"]["reset_credits_fetched_at"].endswith("Z"))
|
|
36
|
+
self.assertEqual(self.server.state["credit_gets"], 1)
|
|
37
|
+
self.assertEqual(self.server.state["posts"], [])
|
|
38
|
+
|
|
39
|
+
def test_spent_account_reports_real_zero(self):
|
|
40
|
+
self.server.state["credits"] = {"available_count": 0, "credits": []}
|
|
41
|
+
account = self.report(100)
|
|
42
|
+
self.assertTrue(account["limited"])
|
|
43
|
+
self.assertEqual(account["usage"]["reset_credits_available"], 0)
|
|
44
|
+
|
|
45
|
+
def test_redemption_reports_readback_instead_of_pre_reset_allowance(self):
|
|
46
|
+
self.server.state["credits"] = {"available_count": 2, "credits": []}
|
|
47
|
+
self.server.state["responses"] = [(200, {"code": "reset", "windows_reset": 1})]
|
|
48
|
+
account = self.report(100)
|
|
49
|
+
self.assertEqual(account["usage"]["reset_credits_available"], 1)
|
|
50
|
+
self.assertTrue(account["usage"]["reset_credits_fetched_at"])
|
|
51
|
+
self.assertEqual(self.server.state["credit_gets"], 2)
|
|
52
|
+
self.assertEqual(len(self.server.state["posts"]), 1)
|
|
53
|
+
|
|
54
|
+
def test_failed_or_invalid_reads_clear_previous_allowance_without_hiding_usage(self):
|
|
55
|
+
self.server.state["credits"] = {"available_count": 2}
|
|
56
|
+
self.report()
|
|
57
|
+
for count in (None, "2", True, -1, 1.5, 1_000_001):
|
|
58
|
+
with self.subTest(count=count):
|
|
59
|
+
self.server.state["credits"] = {"available_count": count}
|
|
60
|
+
usage = self.report()["usage"]
|
|
61
|
+
self.assertNotIn("reset_credits_available", usage)
|
|
62
|
+
self.assertNotIn("reset_credits_fetched_at", usage)
|
|
63
|
+
self.assertEqual(usage["weekly_percent"], 60)
|
|
64
|
+
self.assertEqual(self.server.state["posts"], [])
|
|
65
|
+
|
|
66
|
+
def test_disabled_redemption_still_reports_allowance(self):
|
|
67
|
+
self.server.state["credits"] = {"available_count": 2}
|
|
68
|
+
self.assertEqual(self.report(100, enabled="0")["usage"]["reset_credits_available"], 2)
|
|
69
|
+
self.assertEqual(self.server.state["posts"], [])
|
|
70
|
+
|
|
71
|
+
def test_failed_readback_does_not_reuse_the_allowance_before_redemption(self):
|
|
72
|
+
self.server.state["credits"] = {"available_count": 2, "credits": []}
|
|
73
|
+
self.server.state["credits_after_post"] = []
|
|
74
|
+
self.server.state["responses"] = [(200, {"code": "reset", "windows_reset": 1})]
|
|
75
|
+
account = self.report(100)
|
|
76
|
+
self.assertFalse(account["limited"])
|
|
77
|
+
self.assertNotIn("reset_credits_available", account["usage"])
|
|
78
|
+
self.assertEqual(len(self.server.state["posts"]), 1)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
unittest.main()
|