authforge-sdk 1.3.0__tar.gz → 1.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: authforge-sdk
3
- Version: 1.3.0
3
+ Version: 1.4.0
4
4
  Summary: Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses.
5
5
  Author: AuthForge
6
6
  License: MIT
@@ -153,7 +153,7 @@ first (primary) entry.
153
153
 
154
154
  **Grace period (default).** After a successful activation, the SDK re-verifies the cached signed session payload and checks the expiry timestamp locally at each `heartbeat_interval`: no network calls. When the session TTL expires, it triggers failure with `session_expired`. The grace period is **session continuation within the session TTL** (default 24h, server clamps 1h to 7d) after one successful online activation. It is not persistent offline licensing, and a mid-session revocation is not picked up until the next online validate or check-in.
155
155
 
156
- **Online check-ins (opt-in, `online_heartbeat=True`).** The SDK calls `/auth/heartbeat` every `heartbeat_interval` seconds with a fresh nonce, verifies signature + nonce, and triggers failure on invalid session state. Use this when you need fast revocation propagation or concurrent-use detection.
156
+ **Online check-ins (opt-in, `online_heartbeat=True`).** The SDK calls `/auth/heartbeat` every `heartbeat_interval` seconds with a fresh nonce, verifies signature + nonce, and triggers failure on invalid session state. Use this when you need fast revocation propagation or concurrent-use detection. A definitive rejection (`revoked`, `hwid_mismatch`, `blocked`, ...) clears the stored session immediately; every other failure (network, `rate_limited`, `system_error`, `no_credits`, unrecognized responses, ...) is transient and keeps it until the session TTL runs out. See [Background check failures](#background-check-failures).
157
157
 
158
158
  Tune `ttl_seconds` to set the grace period duration (server default 24h, clamped to 1h to 7d).
159
159
 
@@ -247,25 +247,57 @@ If authentication fails (activation rejected, check-in fails, signature mismatch
247
247
  **`validate_license()`** does not trigger `on_failure` or `os._exit`: check `result["valid"]` and `result["code"]`.
248
248
 
249
249
  Recognized server errors (`KNOWN_SERVER_ERRORS`):
250
- `invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `system_error`
250
+ `invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `demo_quota_exceeded`, `system_error`. Codes added to the server later are passed through unchanged.
251
251
 
252
252
  Request retries are automatic inside the internal HTTP layer:
253
- - `rate_limited`: retry after 2s, then 5s (max 3 attempts total)
253
+ - `rate_limited`, or HTTP 429 with no error code: retry after 2s, then 5s (max 3 attempts total)
254
+ - `no_credits`, `demo_quota_exceeded` and `app_burn_cap_reached` also arrive as HTTP 429 but are never retried immediately; online check-ins try again at the next interval
254
255
  - network failure: retry once after 2s
255
256
  - every retry regenerates a fresh nonce
256
257
 
258
+ ### Background check failures
259
+
260
+ Background check failures reach `on_failure("heartbeat_failed", exc)`, where `exc` is an `AuthForgeError` (a `ValueError` subclass):
261
+
262
+ - `exc.code`: the server's error code from the response body, whatever the HTTP status (`revoked`, `expired`, `hwid_mismatch`, `blocked`, `session_expired`, `rate_limited`, `no_credits`, `system_error`, `malformed_request`, ...), or an SDK code: `network_error`, `timeout`, `http_error_<status>` (non-JSON error body), `invalid_json_response`, `unexpected_response`, `signature_mismatch`, `nonce_mismatch`.
263
+ - `exc.transient` / `exc.fatal`: the classification. `is_transient_error(code_or_exc)` is the same check as a function; `DEFINITIVE_ERROR_CODES` is the full list of fatal codes.
264
+
265
+ A failed check-in only counts as an AuthForge verdict when the body is a JSON object with `"status": "failed"` and a non-empty `error` string. Any other failure body (for example a proxy's JSON, or `{"status": "revoked"}`) is reported as `unexpected_response`, whose message includes the raw `status` and `error`.
266
+
267
+ | Kind | Codes | What the SDK does |
268
+ |---|---|---|
269
+ | Fatal | `revoked`, `expired`, `hwid_mismatch` (the HWID is no longer bound to the license, for example after an HWID reset), `blocked` (HWID/IP blacklisted or not whitelisted), `session_expired` (also raised locally when the grace period or session TTL runs out), `malformed_request`, `app_disabled`, `invalid_app`, `signature_mismatch` | Clears the stored session (as `logout()` does) and stops background checks **before** calling `on_failure`, so the grace period cannot keep the app running on it. `on_failure` may call `login()` again. |
270
+ | Transient | Everything else: `network_error`, `timeout`, `rate_limited`, `system_error`, `no_credits`, `demo_quota_exceeded`, `app_burn_cap_reached`, `bad_request`, `invalid_key`, every `http_error_<status>`, `invalid_json_response`, `unexpected_response`, `nonce_mismatch`, any unrecognized code | Keeps the session and checks in again on the next `heartbeat_interval`. Once the signed session's TTL has passed, the next transient failure is reported as a fatal `session_expired` instead. |
271
+
272
+ Transient failures only keep checking in if `on_failure` returns normally. Without a callback, any failure still ends the process with `os._exit(1)`. Heartbeat network failures are reported once, as `heartbeat_failed` with code `network_error` or `timeout`, not as a separate `network_error` reason.
273
+
274
+ `on_failure` runs on the background heartbeat thread with no SDK lock held, so calling `logout()`, `is_authenticated()` or `login()` from it is safe. A check-in that is still in flight when you call `logout()` or `login()` is discarded: it cannot restore the old session or report a failure for it.
275
+
276
+ To tolerate short outages but exit on a definitive answer:
277
+
257
278
  ```python
258
- def handle_auth_failure(reason, exception):
259
- print(f"Auth failed: {reason}")
260
- if exception:
261
- print(f"Details: {exception}")
262
- # Clean up and exit gracefully
263
- sys.exit(1)
279
+ import os
280
+ import sys
281
+ from authforge import AuthForgeClient, AuthForgeError
282
+
283
+ def handle_auth_failure(reason, exc):
284
+ if reason == "heartbeat_failed" and isinstance(exc, AuthForgeError) and exc.transient:
285
+ # Connectivity problem or AuthForge overloaded: keep running. The SDK
286
+ # retries every heartbeat_interval and reports session_expired (fatal)
287
+ # once the ttl_seconds grace period is used up.
288
+ print(f"AuthForge check-in failed ({exc.code}), retrying", file=sys.stderr)
289
+ return
290
+ code = exc.code if isinstance(exc, AuthForgeError) else exc
291
+ print(f"License check failed: {reason} ({code})", file=sys.stderr)
292
+ # on_failure can run on the heartbeat thread, where sys.exit() only ends that thread.
293
+ os._exit(1)
264
294
 
265
295
  client = AuthForgeClient(
266
296
  app_id="YOUR_APP_ID",
267
297
  app_secret="YOUR_APP_SECRET",
268
298
  public_key="YOUR_PUBLIC_KEY",
299
+ online_heartbeat=True,
300
+ ttl_seconds=3600, # retry window for transient check-in failures
269
301
  on_failure=handle_auth_failure,
270
302
  )
271
303
  ```
@@ -125,7 +125,7 @@ first (primary) entry.
125
125
 
126
126
  **Grace period (default).** After a successful activation, the SDK re-verifies the cached signed session payload and checks the expiry timestamp locally at each `heartbeat_interval`: no network calls. When the session TTL expires, it triggers failure with `session_expired`. The grace period is **session continuation within the session TTL** (default 24h, server clamps 1h to 7d) after one successful online activation. It is not persistent offline licensing, and a mid-session revocation is not picked up until the next online validate or check-in.
127
127
 
128
- **Online check-ins (opt-in, `online_heartbeat=True`).** The SDK calls `/auth/heartbeat` every `heartbeat_interval` seconds with a fresh nonce, verifies signature + nonce, and triggers failure on invalid session state. Use this when you need fast revocation propagation or concurrent-use detection.
128
+ **Online check-ins (opt-in, `online_heartbeat=True`).** The SDK calls `/auth/heartbeat` every `heartbeat_interval` seconds with a fresh nonce, verifies signature + nonce, and triggers failure on invalid session state. Use this when you need fast revocation propagation or concurrent-use detection. A definitive rejection (`revoked`, `hwid_mismatch`, `blocked`, ...) clears the stored session immediately; every other failure (network, `rate_limited`, `system_error`, `no_credits`, unrecognized responses, ...) is transient and keeps it until the session TTL runs out. See [Background check failures](#background-check-failures).
129
129
 
130
130
  Tune `ttl_seconds` to set the grace period duration (server default 24h, clamped to 1h to 7d).
131
131
 
@@ -219,25 +219,57 @@ If authentication fails (activation rejected, check-in fails, signature mismatch
219
219
  **`validate_license()`** does not trigger `on_failure` or `os._exit`: check `result["valid"]` and `result["code"]`.
220
220
 
221
221
  Recognized server errors (`KNOWN_SERVER_ERRORS`):
222
- `invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `system_error`
222
+ `invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `demo_quota_exceeded`, `system_error`. Codes added to the server later are passed through unchanged.
223
223
 
224
224
  Request retries are automatic inside the internal HTTP layer:
225
- - `rate_limited`: retry after 2s, then 5s (max 3 attempts total)
225
+ - `rate_limited`, or HTTP 429 with no error code: retry after 2s, then 5s (max 3 attempts total)
226
+ - `no_credits`, `demo_quota_exceeded` and `app_burn_cap_reached` also arrive as HTTP 429 but are never retried immediately; online check-ins try again at the next interval
226
227
  - network failure: retry once after 2s
227
228
  - every retry regenerates a fresh nonce
228
229
 
230
+ ### Background check failures
231
+
232
+ Background check failures reach `on_failure("heartbeat_failed", exc)`, where `exc` is an `AuthForgeError` (a `ValueError` subclass):
233
+
234
+ - `exc.code`: the server's error code from the response body, whatever the HTTP status (`revoked`, `expired`, `hwid_mismatch`, `blocked`, `session_expired`, `rate_limited`, `no_credits`, `system_error`, `malformed_request`, ...), or an SDK code: `network_error`, `timeout`, `http_error_<status>` (non-JSON error body), `invalid_json_response`, `unexpected_response`, `signature_mismatch`, `nonce_mismatch`.
235
+ - `exc.transient` / `exc.fatal`: the classification. `is_transient_error(code_or_exc)` is the same check as a function; `DEFINITIVE_ERROR_CODES` is the full list of fatal codes.
236
+
237
+ A failed check-in only counts as an AuthForge verdict when the body is a JSON object with `"status": "failed"` and a non-empty `error` string. Any other failure body (for example a proxy's JSON, or `{"status": "revoked"}`) is reported as `unexpected_response`, whose message includes the raw `status` and `error`.
238
+
239
+ | Kind | Codes | What the SDK does |
240
+ |---|---|---|
241
+ | Fatal | `revoked`, `expired`, `hwid_mismatch` (the HWID is no longer bound to the license, for example after an HWID reset), `blocked` (HWID/IP blacklisted or not whitelisted), `session_expired` (also raised locally when the grace period or session TTL runs out), `malformed_request`, `app_disabled`, `invalid_app`, `signature_mismatch` | Clears the stored session (as `logout()` does) and stops background checks **before** calling `on_failure`, so the grace period cannot keep the app running on it. `on_failure` may call `login()` again. |
242
+ | Transient | Everything else: `network_error`, `timeout`, `rate_limited`, `system_error`, `no_credits`, `demo_quota_exceeded`, `app_burn_cap_reached`, `bad_request`, `invalid_key`, every `http_error_<status>`, `invalid_json_response`, `unexpected_response`, `nonce_mismatch`, any unrecognized code | Keeps the session and checks in again on the next `heartbeat_interval`. Once the signed session's TTL has passed, the next transient failure is reported as a fatal `session_expired` instead. |
243
+
244
+ Transient failures only keep checking in if `on_failure` returns normally. Without a callback, any failure still ends the process with `os._exit(1)`. Heartbeat network failures are reported once, as `heartbeat_failed` with code `network_error` or `timeout`, not as a separate `network_error` reason.
245
+
246
+ `on_failure` runs on the background heartbeat thread with no SDK lock held, so calling `logout()`, `is_authenticated()` or `login()` from it is safe. A check-in that is still in flight when you call `logout()` or `login()` is discarded: it cannot restore the old session or report a failure for it.
247
+
248
+ To tolerate short outages but exit on a definitive answer:
249
+
229
250
  ```python
230
- def handle_auth_failure(reason, exception):
231
- print(f"Auth failed: {reason}")
232
- if exception:
233
- print(f"Details: {exception}")
234
- # Clean up and exit gracefully
235
- sys.exit(1)
251
+ import os
252
+ import sys
253
+ from authforge import AuthForgeClient, AuthForgeError
254
+
255
+ def handle_auth_failure(reason, exc):
256
+ if reason == "heartbeat_failed" and isinstance(exc, AuthForgeError) and exc.transient:
257
+ # Connectivity problem or AuthForge overloaded: keep running. The SDK
258
+ # retries every heartbeat_interval and reports session_expired (fatal)
259
+ # once the ttl_seconds grace period is used up.
260
+ print(f"AuthForge check-in failed ({exc.code}), retrying", file=sys.stderr)
261
+ return
262
+ code = exc.code if isinstance(exc, AuthForgeError) else exc
263
+ print(f"License check failed: {reason} ({code})", file=sys.stderr)
264
+ # on_failure can run on the heartbeat thread, where sys.exit() only ends that thread.
265
+ os._exit(1)
236
266
 
237
267
  client = AuthForgeClient(
238
268
  app_id="YOUR_APP_ID",
239
269
  app_secret="YOUR_APP_SECRET",
240
270
  public_key="YOUR_PUBLIC_KEY",
271
+ online_heartbeat=True,
272
+ ttl_seconds=3600, # retry window for transient check-in failures
241
273
  on_failure=handle_auth_failure,
242
274
  )
243
275
  ```
@@ -87,7 +87,7 @@ _ACTIVATION_REQUEST_VERSION = 1
87
87
  _ACTIVATION_REQUEST_TYP = "authforge-activation-request"
88
88
  _BEGIN_ACTIVATION_REQUEST = "-----BEGIN AUTHFORGE ACTIVATION REQUEST-----"
89
89
  _END_ACTIVATION_REQUEST = "-----END AUTHFORGE ACTIVATION REQUEST-----"
90
- _SDK_TAG = "python/1.2.1"
90
+ _SDK_TAG = "python/1.4.0"
91
91
  _ARMOR_LINE_WIDTH = 64
92
92
  _MAX_REQUEST_HWID = 256
93
93
  _MAX_REQUEST_MACHINE_NAME = 128
@@ -492,9 +492,89 @@ KNOWN_SERVER_ERRORS = {
492
492
  "revoke_requires_session",
493
493
  "bad_request",
494
494
  "malformed_request",
495
+ "demo_quota_exceeded",
495
496
  "system_error",
496
497
  }
497
498
 
499
+ # Well-formed AuthForge verdicts that end the session. This is the only list
500
+ # the classification uses: every other code is transient.
501
+ DEFINITIVE_ERROR_CODES = frozenset(
502
+ {
503
+ "revoked",
504
+ "expired",
505
+ "hwid_mismatch",
506
+ "blocked",
507
+ "session_expired",
508
+ "malformed_request",
509
+ "app_disabled",
510
+ "invalid_app",
511
+ "signature_mismatch",
512
+ }
513
+ )
514
+
515
+ # Reference list of the named transient codes. Informational only: any code
516
+ # outside DEFINITIVE_ERROR_CODES is transient, including every
517
+ # http_error_<status>, unexpected_response and codes the server adds later.
518
+ TRANSIENT_ERROR_CODES = frozenset(
519
+ {
520
+ "network_error",
521
+ "timeout",
522
+ "rate_limited",
523
+ "system_error",
524
+ "handler_error",
525
+ "invalid_json_response",
526
+ "response_not_json_object",
527
+ "unexpected_response",
528
+ "no_credits",
529
+ "demo_quota_exceeded",
530
+ "app_burn_cap_reached",
531
+ "bad_request",
532
+ "invalid_key",
533
+ "replay_detected",
534
+ "revoke_requires_session",
535
+ "missing_session_token",
536
+ "nonce_mismatch",
537
+ "unknown_error",
538
+ }
539
+ )
540
+ _SERVER_ERROR_CODE_RE = re.compile(r"[a-z][a-z0-9_]{0,63}")
541
+
542
+
543
+ class AuthForgeError(ValueError):
544
+ """A failure with a machine-readable ``code``: the server's error code
545
+ (``revoked``, ``hwid_mismatch``, ...) or an SDK code (``network_error``,
546
+ ``timeout``, ``http_error_502``, ...). ``str(exc)`` is the human message,
547
+ which equals ``code`` for server errors."""
548
+
549
+ def __init__(self, code: str, message: Optional[str] = None) -> None:
550
+ super().__init__(message if message is not None else code)
551
+ self.code = code
552
+
553
+ @property
554
+ def transient(self) -> bool:
555
+ """True when retrying later can succeed (see :func:`is_transient_error`)."""
556
+ return is_transient_error(self.code)
557
+
558
+ @property
559
+ def fatal(self) -> bool:
560
+ """True when AuthForge definitively rejected the session or license."""
561
+ return not self.transient
562
+
563
+
564
+ def is_transient_error(error: Union[str, BaseException, None]) -> bool:
565
+ """Classify a failure code (or an exception from ``on_failure``).
566
+
567
+ Definitive (fatal) only for the codes in :data:`DEFINITIVE_ERROR_CODES`.
568
+ Everything else is transient: network failures, ``rate_limited``,
569
+ ``no_credits``, ``http_error_<status>``, ``unexpected_response``, unknown
570
+ codes, ... ``None`` and exceptions other than :class:`AuthForgeError`
571
+ carry no AuthForge verdict and are transient too.
572
+ """
573
+ code = error.code if isinstance(error, AuthForgeError) else error
574
+ if not isinstance(code, str):
575
+ return True
576
+ return code not in DEFINITIVE_ERROR_CODES
577
+
498
578
 
499
579
  class AuthForgeClient:
500
580
  def __init__(
@@ -573,6 +653,9 @@ class AuthForgeClient:
573
653
  self._heartbeat_thread: Optional[threading.Thread] = None
574
654
  self._heartbeat_started = False
575
655
  self._heartbeat_stop = threading.Event()
656
+ # Bumped whenever the stored session is replaced or cleared, so a
657
+ # heartbeat that started before logout()/login() can't write back.
658
+ self._session_generation = 0
576
659
 
577
660
  self._license_key: Optional[str] = None
578
661
  self._session_token: Optional[str] = None
@@ -880,31 +963,80 @@ class AuthForgeClient:
880
963
  # the file's own expires_at is the only clock. Never start a thread.
881
964
  if self._heartbeat_started or self._session_kind == "offline":
882
965
  return
883
- self._heartbeat_stop.clear()
966
+ # A fresh event per thread: logout() stops this loop even if a
967
+ # later login() starts another one before this one wakes up.
968
+ stop = threading.Event()
969
+ self._heartbeat_stop = stop
884
970
  self._heartbeat_started = True
885
971
  self._heartbeat_thread = threading.Thread(
886
972
  target=self._heartbeat_loop,
973
+ args=(stop,),
887
974
  name="AuthForgeHeartbeat",
888
975
  daemon=True,
889
976
  )
890
977
  self._heartbeat_thread.start()
891
978
 
892
- def _heartbeat_loop(self) -> None:
893
- while not self._heartbeat_stop.wait(self.heartbeat_interval):
894
- with self._lock:
895
- offline = self._session_kind == "offline"
896
- if offline:
897
- return
898
- try:
899
- if self.online_heartbeat:
900
- self._server_heartbeat()
901
- else:
902
- self._grace_period_check()
903
- except Exception as exc:
904
- self._fail("heartbeat_failed", exc)
979
+ def _heartbeat_loop(self, stop: Optional[threading.Event] = None) -> None:
980
+ stop = stop if stop is not None else self._heartbeat_stop
981
+ while not stop.wait(self.heartbeat_interval):
982
+ if not self._heartbeat_tick():
905
983
  break
906
984
 
907
- def _server_heartbeat(self) -> None:
985
+ def _heartbeat_tick(self) -> bool:
986
+ """Run one background check. Returns ``False`` when the loop must stop.
987
+
988
+ Transient failures are reported and the loop keeps checking in on the
989
+ next interval. Definitive failures drop the stored session first, so
990
+ neither the grace period nor ``is_authenticated()`` keeps the app
991
+ running on it, and ``on_failure`` may call ``login()`` again.
992
+ ``on_failure`` always runs with no SDK lock held.
993
+ """
994
+ with self._lock:
995
+ offline = self._session_kind == "offline"
996
+ generation = self._session_generation
997
+ if offline:
998
+ return False
999
+ try:
1000
+ if self.online_heartbeat:
1001
+ self._server_heartbeat(generation)
1002
+ else:
1003
+ self._grace_period_check()
1004
+ return True
1005
+ except Exception as exc:
1006
+ error = self._heartbeat_error(exc)
1007
+ with self._lock:
1008
+ # The session this check ran for was logged out or replaced
1009
+ # meanwhile: its failure is moot and must not touch the new state.
1010
+ if generation != self._session_generation:
1011
+ return True
1012
+ if error.fatal:
1013
+ self._clear_session_locked()
1014
+ self._fail("heartbeat_failed", error)
1015
+ return error.transient
1016
+
1017
+ def _heartbeat_error(self, exc: Exception) -> AuthForgeError:
1018
+ if isinstance(exc, AuthForgeError):
1019
+ error = exc
1020
+ else:
1021
+ message = str(exc)
1022
+ code = message.split(":", 1)[0].strip().lower()
1023
+ if not _SERVER_ERROR_CODE_RE.fullmatch(code):
1024
+ code = "unknown_error"
1025
+ error = AuthForgeError(code, message)
1026
+ error.__cause__ = exc
1027
+ # A transient failure can't extend the session past its signed TTL.
1028
+ if error.transient and self._local_session_expired():
1029
+ expired = AuthForgeError("session_expired")
1030
+ expired.__cause__ = error
1031
+ return expired
1032
+ return error
1033
+
1034
+ def _local_session_expired(self) -> bool:
1035
+ with self._lock:
1036
+ expires_in = self._session_expires_in
1037
+ return expires_in is not None and int(time.time()) >= int(expires_in)
1038
+
1039
+ def _server_heartbeat(self, generation: Optional[int] = None) -> None:
908
1040
  with self._lock:
909
1041
  session_token = self._session_token
910
1042
  hwid = self._hwid
@@ -917,13 +1049,35 @@ class AuthForgeClient:
917
1049
  "nonce": self._generate_nonce(),
918
1050
  "hwid": hwid,
919
1051
  }
920
- response_obj = self._post_json("/auth/heartbeat", body)
1052
+ # Network failures surface once, as heartbeat_failed / network_error.
1053
+ response_obj = self._post_json("/auth/heartbeat", body, skip_failure_hook=True)
1054
+ if not self._is_success_status(response_obj.get("status")):
1055
+ raise self._heartbeat_verdict(response_obj)
921
1056
  expected_nonce = str(body.get("nonce", "")).strip()
922
1057
  self._apply_signed_response(
923
1058
  response_obj,
924
1059
  expected_nonce=expected_nonce,
925
1060
  license_key=None,
926
1061
  context="heartbeat",
1062
+ generation=generation,
1063
+ )
1064
+
1065
+ def _heartbeat_verdict(self, response_obj: Dict[str, Any]) -> AuthForgeError:
1066
+ # Only {"status": "failed", "error": "<code>"} is an AuthForge verdict.
1067
+ # Anything else (a proxy's JSON, a half-written body) must not be able
1068
+ # to end the session with a definitive code.
1069
+ status = response_obj.get("status")
1070
+ error = response_obj.get("error")
1071
+ if (
1072
+ isinstance(status, str)
1073
+ and status.strip().lower() == "failed"
1074
+ and isinstance(error, str)
1075
+ and error.strip()
1076
+ ):
1077
+ return AuthForgeError(self._extract_server_error(response_obj))
1078
+ return AuthForgeError(
1079
+ "unexpected_response",
1080
+ f"unexpected_response: status={status!r} error={error!r}",
927
1081
  )
928
1082
 
929
1083
  def _grace_period_check(self) -> None:
@@ -943,7 +1097,7 @@ class AuthForgeClient:
943
1097
 
944
1098
  now = int(time.time())
945
1099
  if now >= int(expires_in):
946
- raise RuntimeError("session_expired")
1100
+ raise AuthForgeError("session_expired")
947
1101
 
948
1102
  def _validate_and_store(self, license_key: str) -> None:
949
1103
  body: Dict[str, Any] = {
@@ -969,8 +1123,7 @@ class AuthForgeClient:
969
1123
  ) -> Dict[str, Any]:
970
1124
  status = response_obj.get("status")
971
1125
  if not self._is_success_status(status):
972
- error_code = self._extract_server_error(response_obj)
973
- raise ValueError(error_code)
1126
+ raise AuthForgeError(self._extract_server_error(response_obj))
974
1127
 
975
1128
  raw_payload_b64 = self._require_str(response_obj, "payload")
976
1129
  signature = self._require_str(response_obj, "signature")
@@ -1037,12 +1190,16 @@ class AuthForgeClient:
1037
1190
  expected_nonce: str,
1038
1191
  license_key: Optional[str],
1039
1192
  context: str,
1193
+ generation: Optional[int] = None,
1040
1194
  ) -> None:
1041
1195
  parsed = self._parse_validate_success(response_obj, expected_nonce)
1042
1196
  _ = context
1043
1197
 
1044
1198
  with self._lock:
1199
+ if generation is not None and generation != self._session_generation:
1200
+ return
1045
1201
  if license_key is not None:
1202
+ self._session_generation += 1
1046
1203
  self._license_key = license_key
1047
1204
  self._session_token = parsed["session_token"]
1048
1205
  self._session_kind = "online"
@@ -1089,7 +1246,7 @@ class AuthForgeClient:
1089
1246
  detail = exc.read().decode("utf-8")
1090
1247
  obj = self._parse_response_object(detail)
1091
1248
  except Exception:
1092
- raise RuntimeError(f"http_error_{status_code}") from exc
1249
+ raise AuthForgeError(f"http_error_{status_code}") from exc
1093
1250
  data.clear()
1094
1251
  data.update(body)
1095
1252
  break
@@ -1100,11 +1257,15 @@ class AuthForgeClient:
1100
1257
  continue
1101
1258
  if not skip_failure_hook:
1102
1259
  self._fail("network_error", exc)
1103
- raise RuntimeError(f"url_error: {exc}") from exc
1104
-
1105
- is_rate_limited = (
1106
- status_code == 429
1107
- or self._extract_server_error(obj) == "rate_limited"
1260
+ reason = getattr(exc, "reason", exc)
1261
+ code = "timeout" if isinstance(reason, (socket.timeout, TimeoutError)) else "network_error"
1262
+ raise AuthForgeError(code, f"url_error: {exc}") from exc
1263
+
1264
+ # no_credits / app_burn_cap_reached also use HTTP 429 but are not
1265
+ # worth retrying; only retry a genuine rate limit.
1266
+ server_error = self._extract_server_error(obj)
1267
+ is_rate_limited = server_error == "rate_limited" or (
1268
+ status_code == 429 and server_error == "unknown_error"
1108
1269
  )
1109
1270
  if is_rate_limited and rate_attempt < len(RATE_LIMIT_RETRY_DELAYS):
1110
1271
  time.sleep(RATE_LIMIT_RETRY_DELAYS[rate_attempt])
@@ -1116,9 +1277,9 @@ class AuthForgeClient:
1116
1277
  try:
1117
1278
  obj = json.loads(raw_response)
1118
1279
  except json.JSONDecodeError as exc:
1119
- raise ValueError("invalid_json_response") from exc
1280
+ raise AuthForgeError("invalid_json_response") from exc
1120
1281
  if not isinstance(obj, dict):
1121
- raise ValueError("response_not_json_object")
1282
+ raise AuthForgeError("response_not_json_object")
1122
1283
  return obj
1123
1284
 
1124
1285
  def _get_hwid(self) -> str:
@@ -1298,8 +1459,11 @@ class AuthForgeClient:
1298
1459
  return text
1299
1460
 
1300
1461
  def _extract_server_error(self, obj: Dict[str, Any]) -> str:
1301
- raw_error = str(obj.get("error", "")).strip().lower()
1302
- if raw_error in KNOWN_SERVER_ERRORS:
1462
+ # Pass through codes this SDK version doesn't know yet instead of
1463
+ # collapsing them into unknown_error.
1464
+ raw = obj.get("error")
1465
+ raw_error = raw.strip().lower() if isinstance(raw, str) else ""
1466
+ if raw_error in KNOWN_SERVER_ERRORS or _SERVER_ERROR_CODE_RE.fullmatch(raw_error):
1303
1467
  return raw_error
1304
1468
  status = str(obj.get("status", "")).strip().lower()
1305
1469
  if status in KNOWN_SERVER_ERRORS:
@@ -1321,23 +1485,29 @@ class AuthForgeClient:
1321
1485
  os._exit(1)
1322
1486
 
1323
1487
  def logout(self) -> None:
1324
- self._heartbeat_stop.set()
1488
+ """Clear the session and stop background checks. Never joins the
1489
+ heartbeat thread, so it is safe to call from ``on_failure``."""
1325
1490
  with self._lock:
1326
- self._license_key = None
1327
- self._session_token = None
1328
- self._session_kind = None
1329
- self._session_expires_in = None
1330
- self._last_nonce = None
1331
- self._raw_payload_b64 = None
1332
- self._signature = None
1333
- self._key_id = None
1334
- self._session_data = None
1335
- self._app_variables = None
1336
- self._license_variables = None
1337
- self._authenticated = False
1338
- self._offline_license = None
1339
- self._heartbeat_started = False
1340
- self._heartbeat_thread = None
1491
+ self._clear_session_locked()
1492
+
1493
+ def _clear_session_locked(self) -> None:
1494
+ self._heartbeat_stop.set()
1495
+ self._session_generation += 1
1496
+ self._license_key = None
1497
+ self._session_token = None
1498
+ self._session_kind = None
1499
+ self._session_expires_in = None
1500
+ self._last_nonce = None
1501
+ self._raw_payload_b64 = None
1502
+ self._signature = None
1503
+ self._key_id = None
1504
+ self._session_data = None
1505
+ self._app_variables = None
1506
+ self._license_variables = None
1507
+ self._authenticated = False
1508
+ self._offline_license = None
1509
+ self._heartbeat_started = False
1510
+ self._heartbeat_thread = None
1341
1511
 
1342
1512
  def is_authenticated(self) -> bool:
1343
1513
  """True for an online session (:meth:`login`) or an offline one
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: authforge-sdk
3
- Version: 1.3.0
3
+ Version: 1.4.0
4
4
  Summary: Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses.
5
5
  Author: AuthForge
6
6
  License: MIT
@@ -153,7 +153,7 @@ first (primary) entry.
153
153
 
154
154
  **Grace period (default).** After a successful activation, the SDK re-verifies the cached signed session payload and checks the expiry timestamp locally at each `heartbeat_interval`: no network calls. When the session TTL expires, it triggers failure with `session_expired`. The grace period is **session continuation within the session TTL** (default 24h, server clamps 1h to 7d) after one successful online activation. It is not persistent offline licensing, and a mid-session revocation is not picked up until the next online validate or check-in.
155
155
 
156
- **Online check-ins (opt-in, `online_heartbeat=True`).** The SDK calls `/auth/heartbeat` every `heartbeat_interval` seconds with a fresh nonce, verifies signature + nonce, and triggers failure on invalid session state. Use this when you need fast revocation propagation or concurrent-use detection.
156
+ **Online check-ins (opt-in, `online_heartbeat=True`).** The SDK calls `/auth/heartbeat` every `heartbeat_interval` seconds with a fresh nonce, verifies signature + nonce, and triggers failure on invalid session state. Use this when you need fast revocation propagation or concurrent-use detection. A definitive rejection (`revoked`, `hwid_mismatch`, `blocked`, ...) clears the stored session immediately; every other failure (network, `rate_limited`, `system_error`, `no_credits`, unrecognized responses, ...) is transient and keeps it until the session TTL runs out. See [Background check failures](#background-check-failures).
157
157
 
158
158
  Tune `ttl_seconds` to set the grace period duration (server default 24h, clamped to 1h to 7d).
159
159
 
@@ -247,25 +247,57 @@ If authentication fails (activation rejected, check-in fails, signature mismatch
247
247
  **`validate_license()`** does not trigger `on_failure` or `os._exit`: check `result["valid"]` and `result["code"]`.
248
248
 
249
249
  Recognized server errors (`KNOWN_SERVER_ERRORS`):
250
- `invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `system_error`
250
+ `invalid_app`, `invalid_key`, `expired`, `revoked`, `hwid_mismatch`, `no_credits`, `app_burn_cap_reached`, `blocked`, `rate_limited`, `replay_detected`, `app_disabled`, `session_expired`, `revoke_requires_session`, `bad_request`, `malformed_request`, `demo_quota_exceeded`, `system_error`. Codes added to the server later are passed through unchanged.
251
251
 
252
252
  Request retries are automatic inside the internal HTTP layer:
253
- - `rate_limited`: retry after 2s, then 5s (max 3 attempts total)
253
+ - `rate_limited`, or HTTP 429 with no error code: retry after 2s, then 5s (max 3 attempts total)
254
+ - `no_credits`, `demo_quota_exceeded` and `app_burn_cap_reached` also arrive as HTTP 429 but are never retried immediately; online check-ins try again at the next interval
254
255
  - network failure: retry once after 2s
255
256
  - every retry regenerates a fresh nonce
256
257
 
258
+ ### Background check failures
259
+
260
+ Background check failures reach `on_failure("heartbeat_failed", exc)`, where `exc` is an `AuthForgeError` (a `ValueError` subclass):
261
+
262
+ - `exc.code`: the server's error code from the response body, whatever the HTTP status (`revoked`, `expired`, `hwid_mismatch`, `blocked`, `session_expired`, `rate_limited`, `no_credits`, `system_error`, `malformed_request`, ...), or an SDK code: `network_error`, `timeout`, `http_error_<status>` (non-JSON error body), `invalid_json_response`, `unexpected_response`, `signature_mismatch`, `nonce_mismatch`.
263
+ - `exc.transient` / `exc.fatal`: the classification. `is_transient_error(code_or_exc)` is the same check as a function; `DEFINITIVE_ERROR_CODES` is the full list of fatal codes.
264
+
265
+ A failed check-in only counts as an AuthForge verdict when the body is a JSON object with `"status": "failed"` and a non-empty `error` string. Any other failure body (for example a proxy's JSON, or `{"status": "revoked"}`) is reported as `unexpected_response`, whose message includes the raw `status` and `error`.
266
+
267
+ | Kind | Codes | What the SDK does |
268
+ |---|---|---|
269
+ | Fatal | `revoked`, `expired`, `hwid_mismatch` (the HWID is no longer bound to the license, for example after an HWID reset), `blocked` (HWID/IP blacklisted or not whitelisted), `session_expired` (also raised locally when the grace period or session TTL runs out), `malformed_request`, `app_disabled`, `invalid_app`, `signature_mismatch` | Clears the stored session (as `logout()` does) and stops background checks **before** calling `on_failure`, so the grace period cannot keep the app running on it. `on_failure` may call `login()` again. |
270
+ | Transient | Everything else: `network_error`, `timeout`, `rate_limited`, `system_error`, `no_credits`, `demo_quota_exceeded`, `app_burn_cap_reached`, `bad_request`, `invalid_key`, every `http_error_<status>`, `invalid_json_response`, `unexpected_response`, `nonce_mismatch`, any unrecognized code | Keeps the session and checks in again on the next `heartbeat_interval`. Once the signed session's TTL has passed, the next transient failure is reported as a fatal `session_expired` instead. |
271
+
272
+ Transient failures only keep checking in if `on_failure` returns normally. Without a callback, any failure still ends the process with `os._exit(1)`. Heartbeat network failures are reported once, as `heartbeat_failed` with code `network_error` or `timeout`, not as a separate `network_error` reason.
273
+
274
+ `on_failure` runs on the background heartbeat thread with no SDK lock held, so calling `logout()`, `is_authenticated()` or `login()` from it is safe. A check-in that is still in flight when you call `logout()` or `login()` is discarded: it cannot restore the old session or report a failure for it.
275
+
276
+ To tolerate short outages but exit on a definitive answer:
277
+
257
278
  ```python
258
- def handle_auth_failure(reason, exception):
259
- print(f"Auth failed: {reason}")
260
- if exception:
261
- print(f"Details: {exception}")
262
- # Clean up and exit gracefully
263
- sys.exit(1)
279
+ import os
280
+ import sys
281
+ from authforge import AuthForgeClient, AuthForgeError
282
+
283
+ def handle_auth_failure(reason, exc):
284
+ if reason == "heartbeat_failed" and isinstance(exc, AuthForgeError) and exc.transient:
285
+ # Connectivity problem or AuthForge overloaded: keep running. The SDK
286
+ # retries every heartbeat_interval and reports session_expired (fatal)
287
+ # once the ttl_seconds grace period is used up.
288
+ print(f"AuthForge check-in failed ({exc.code}), retrying", file=sys.stderr)
289
+ return
290
+ code = exc.code if isinstance(exc, AuthForgeError) else exc
291
+ print(f"License check failed: {reason} ({code})", file=sys.stderr)
292
+ # on_failure can run on the heartbeat thread, where sys.exit() only ends that thread.
293
+ os._exit(1)
264
294
 
265
295
  client = AuthForgeClient(
266
296
  app_id="YOUR_APP_ID",
267
297
  app_secret="YOUR_APP_SECRET",
268
298
  public_key="YOUR_PUBLIC_KEY",
299
+ online_heartbeat=True,
300
+ ttl_seconds=3600, # retry window for transient check-in failures
269
301
  on_failure=handle_auth_failure,
270
302
  )
271
303
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "authforge-sdk"
7
- version = "1.3.0"
7
+ version = "1.4.0"
8
8
  description = "Official Python SDK for AuthForge — credit-based license key authentication with Ed25519-verified responses."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes
File without changes