rust-py-audit 0.2.3__tar.gz → 0.3.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.
Files changed (44) hide show
  1. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/Cargo.lock +1 -1
  2. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/Cargo.toml +1 -1
  3. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/PKG-INFO +17 -8
  4. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/README.md +16 -7
  5. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/pyproject.toml +1 -1
  6. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/python/rust_py_audit/__init__.py +1 -1
  7. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/audit_logger.rs +81 -47
  8. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/immutablelog_receipt.rs +12 -0
  9. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_flush_pending.py +37 -4
  10. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_immutablelog_hybrid.py +28 -3
  11. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/.github/workflows/release.yml +0 -0
  12. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/.gitignore +0 -0
  13. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/LICENSE +0 -0
  14. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/examples/__init__.py +0 -0
  15. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/examples/basic_usage.py +0 -0
  16. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/examples/django_example/__init__.py +0 -0
  17. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/examples/django_example/settings.py +0 -0
  18. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/examples/django_example/urls.py +0 -0
  19. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/examples/django_example/views.py +0 -0
  20. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/examples/fastapi_app.py +0 -0
  21. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/examples/immutablelog_basic.py +0 -0
  22. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/python/rust_py_audit/django.py +0 -0
  23. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/python/rust_py_audit/fastapi.py +0 -0
  24. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/errors.rs +0 -0
  25. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/event.rs +0 -0
  26. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/hash.rs +0 -0
  27. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/immutablelog_client.rs +0 -0
  28. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/immutablelog_config.rs +0 -0
  29. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/lib.rs +0 -0
  30. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/pyconv.rs +0 -0
  31. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/retry.rs +0 -0
  32. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/storage.rs +0 -0
  33. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/verifier.rs +0 -0
  34. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/conftest.py +0 -0
  35. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_audit_logger.py +0 -0
  36. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_concurrency.py +0 -0
  37. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_django_immutablelog.py +0 -0
  38. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_django_middleware.py +0 -0
  39. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_fastapi_immutablelog.py +0 -0
  40. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_fastapi_middleware.py +0 -0
  41. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_hello.py +0 -0
  42. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_immutablelog_remote.py +0 -0
  43. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_metadata_bigint.py +0 -0
  44. {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_verify.py +0 -0
@@ -1597,7 +1597,7 @@ dependencies = [
1597
1597
 
1598
1598
  [[package]]
1599
1599
  name = "rust_py_audit"
1600
- version = "0.2.3"
1600
+ version = "0.3.0"
1601
1601
  dependencies = [
1602
1602
  "httpmock",
1603
1603
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "rust_py_audit"
3
- version = "0.2.3"
3
+ version = "0.3.0"
4
4
  edition = "2021"
5
5
  description = "Auditoria de eventos com cadeia de hashes (SHA-256) para apps Python, core em Rust via PyO3."
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rust-py-audit
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -55,6 +55,7 @@ Records audit events (`who did what, when, on which resource`) in a fast, struct
55
55
  - **`AuditLogger`** — simple API: `log(...)`, `verify()`, `last_hash()`
56
56
  - **Hash chain (SHA-256)** — each event embeds the hash of the previous event; altering any recorded event breaks the chain in a detectable way
57
57
  - **JSONL storage** — one event per line, append-only, no database required
58
+ - **Thread-safe** — a single `AuditLogger` can be shared across threads (e.g. a multi-threaded WSGI server, or one middleware instance serving concurrent requests); the hash chain stays linear under concurrency
58
59
  - **Free-form `metadata`** — any JSON-serializable `dict` (IP, reason, request_id, etc.)
59
60
  - **FastAPI middleware** — automatically logs state-changing requests (POST/PUT/PATCH/DELETE)
60
61
  - **Django middleware** — same idea, supports WSGI and ASGI
@@ -197,7 +198,7 @@ See the full example in [`examples/django_example/`](examples/django_example/).
197
198
  |---|---|---|---|
198
199
  | `"local"` (default) | ✅ | ❌ | The library's original behavior, no external dependency |
199
200
  | `"remote"` | ❌ (except for pending entries, see below) | ✅ | ImmutableLog is the single source of truth; a delivery failure raises an exception |
200
- | `"hybrid"` | ✅ | ✅ | Local chain + remote receipt; a delivery failure NEVER raises — it becomes `delivery_status="pending"` |
201
+ | `"hybrid"` | ✅ | ✅ | Local chain + remote receipt; a delivery failure NEVER raises — a transient (retryable) failure becomes `status="pending"` (queued for `flush_pending()`), a permanent one becomes `status="failed"` (not queued) |
201
202
 
202
203
  `mode="local"` is the default — existing code calling `AuditLogger(app_name, file_path)` keeps working unchanged.
203
204
 
@@ -227,11 +228,12 @@ event = audit.log(
227
228
 
228
229
  print(event["immutablelog"])
229
230
  # {"status": "delivered", "tx_id": "tx_...", "payload_hash": "...", ...}
230
- # or {"status": "pending", "tx_id": None, ...} if delivery failed (hybrid mode)
231
+ # or {"status": "pending", "tx_id": None, ...} on a transient failure (hybrid mode)
232
+ # or {"status": "failed", "tx_id": None, ...} on a permanent failure (hybrid mode)
231
233
 
232
234
  # Retries delivery of every event still marked "pending":
233
235
  print(audit.flush_pending())
234
- # {"flushed": 1, "still_pending": 0, "total": 1}
236
+ # {"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
235
237
  ```
236
238
 
237
239
  ### Environment variables
@@ -318,7 +320,11 @@ Same behavior as FastAPI: `severity` computed from `status_code`, and delivery f
318
320
  - **Retryable**: `5xx` and timeouts.
319
321
  - **Permanent** (never retried): `400`, `401`, `403`, `429`, and any other client error.
320
322
  - In `mode="remote"`, exhausting retries (or a permanent error) raises `RuntimeError`.
321
- - In `mode="hybrid"`, the same scenario marks the event as `delivery_status="pending"`, writes it to `audit.pending.jsonl`, and never raises — call `audit.flush_pending()` (manually, or from a cron/worker) to retry later.
323
+ - In `mode="hybrid"`, delivery never raises:
324
+ - a **retryable** failure (5xx/timeout) marks the event `status="pending"` and queues it in `audit.pending.jsonl` — call `audit.flush_pending()` (manually, or from a cron/worker) to retry later;
325
+ - a **permanent** failure (4xx) marks the event `status="failed"` and does **not** queue it (retrying would never succeed, so it stays out of the queue instead of getting stuck there forever). The event is still recorded locally and the chain stays valid — `"failed"` is just operational metadata.
326
+
327
+ In `mode="hybrid"`, the event is appended to the local JSONL **once**, already carrying its final receipt (an O(1) append per event, not a full-file rewrite). A network failure still records the event locally (as `pending`/`failed`), so it is never lost to a failed delivery; the only loss window is the process being killed mid-request, and even then a delivery that did reach ImmutableLog is preserved in the remote store.
322
328
 
323
329
  ### Integrity guarantee
324
330
 
@@ -370,7 +376,7 @@ Records an event and returns the full event (already with `id`, `timestamp`, `ha
370
376
  | `hash` | `str` | SHA-256 (64 hex chars) of the event + `previous_hash` |
371
377
  | `severity` | `str \| absent` | Only present if passed to `log()`. Becomes `meta.type` on ImmutableLog |
372
378
  | `immutable_trail` | `str \| absent` | Only present if passed to `log()` (and not empty after sanitization). Becomes `meta.immutable_trail` |
373
- | `immutablelog` | `dict \| absent` | Only present in `mode="remote"`/`"hybrid"`. `status` is `"delivered"` or `"pending"`; other fields (`tx_id`, `payload_hash`, `duplicate`, `request_id`, ...) come from the ImmutableLog response |
379
+ | `immutablelog` | `dict \| absent` | Only present in `mode="remote"`/`"hybrid"`. `status` is `"delivered"`, `"pending"` (transient failure, queued), or `"failed"` (permanent failure, not queued); other fields (`tx_id`, `payload_hash`, `duplicate`, `request_id`, ...) come from the ImmutableLog response |
374
380
 
375
381
  In `mode="remote"`, a permanent failure or exhausted retries raise `RuntimeError` instead of returning the dict.
376
382
 
@@ -393,10 +399,13 @@ Hash of the last recorded event (in-memory cache, O(1)) — `None` if no event h
393
399
  Attempts to redeliver to ImmutableLog every event marked as `pending` (recorded in `audit.pending.jsonl`, derived from `file_path`). Only relevant in `mode="hybrid"` — other modes never populate this queue.
394
400
 
395
401
  ```python
396
- {"flushed": 1, "still_pending": 0, "total": 1}
402
+ {"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
397
403
  ```
398
404
 
399
- On success, updates `event["immutablelog"]` in `audit.jsonl` (without changing `hash`) and removes the event from the queue. Events that fail again stay in the queue for the next call.
405
+ For each queued event:
406
+ - **delivered** → updates `event["immutablelog"]` in `audit.jsonl` to `"delivered"` (without changing `hash`) and removes it from the queue (counts toward `flushed`);
407
+ - **permanent failure** → marks it `"failed"` in `audit.jsonl` and removes it from the queue, so it doesn't stay stuck forever (counts toward `failed`);
408
+ - **retryable failure** → left in the queue for the next call (counts toward `still_pending`).
400
409
 
401
410
  ---
402
411
 
@@ -18,6 +18,7 @@ Records audit events (`who did what, when, on which resource`) in a fast, struct
18
18
  - **`AuditLogger`** — simple API: `log(...)`, `verify()`, `last_hash()`
19
19
  - **Hash chain (SHA-256)** — each event embeds the hash of the previous event; altering any recorded event breaks the chain in a detectable way
20
20
  - **JSONL storage** — one event per line, append-only, no database required
21
+ - **Thread-safe** — a single `AuditLogger` can be shared across threads (e.g. a multi-threaded WSGI server, or one middleware instance serving concurrent requests); the hash chain stays linear under concurrency
21
22
  - **Free-form `metadata`** — any JSON-serializable `dict` (IP, reason, request_id, etc.)
22
23
  - **FastAPI middleware** — automatically logs state-changing requests (POST/PUT/PATCH/DELETE)
23
24
  - **Django middleware** — same idea, supports WSGI and ASGI
@@ -160,7 +161,7 @@ See the full example in [`examples/django_example/`](examples/django_example/).
160
161
  |---|---|---|---|
161
162
  | `"local"` (default) | ✅ | ❌ | The library's original behavior, no external dependency |
162
163
  | `"remote"` | ❌ (except for pending entries, see below) | ✅ | ImmutableLog is the single source of truth; a delivery failure raises an exception |
163
- | `"hybrid"` | ✅ | ✅ | Local chain + remote receipt; a delivery failure NEVER raises — it becomes `delivery_status="pending"` |
164
+ | `"hybrid"` | ✅ | ✅ | Local chain + remote receipt; a delivery failure NEVER raises — a transient (retryable) failure becomes `status="pending"` (queued for `flush_pending()`), a permanent one becomes `status="failed"` (not queued) |
164
165
 
165
166
  `mode="local"` is the default — existing code calling `AuditLogger(app_name, file_path)` keeps working unchanged.
166
167
 
@@ -190,11 +191,12 @@ event = audit.log(
190
191
 
191
192
  print(event["immutablelog"])
192
193
  # {"status": "delivered", "tx_id": "tx_...", "payload_hash": "...", ...}
193
- # or {"status": "pending", "tx_id": None, ...} if delivery failed (hybrid mode)
194
+ # or {"status": "pending", "tx_id": None, ...} on a transient failure (hybrid mode)
195
+ # or {"status": "failed", "tx_id": None, ...} on a permanent failure (hybrid mode)
194
196
 
195
197
  # Retries delivery of every event still marked "pending":
196
198
  print(audit.flush_pending())
197
- # {"flushed": 1, "still_pending": 0, "total": 1}
199
+ # {"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
198
200
  ```
199
201
 
200
202
  ### Environment variables
@@ -281,7 +283,11 @@ Same behavior as FastAPI: `severity` computed from `status_code`, and delivery f
281
283
  - **Retryable**: `5xx` and timeouts.
282
284
  - **Permanent** (never retried): `400`, `401`, `403`, `429`, and any other client error.
283
285
  - In `mode="remote"`, exhausting retries (or a permanent error) raises `RuntimeError`.
284
- - In `mode="hybrid"`, the same scenario marks the event as `delivery_status="pending"`, writes it to `audit.pending.jsonl`, and never raises — call `audit.flush_pending()` (manually, or from a cron/worker) to retry later.
286
+ - In `mode="hybrid"`, delivery never raises:
287
+ - a **retryable** failure (5xx/timeout) marks the event `status="pending"` and queues it in `audit.pending.jsonl` — call `audit.flush_pending()` (manually, or from a cron/worker) to retry later;
288
+ - a **permanent** failure (4xx) marks the event `status="failed"` and does **not** queue it (retrying would never succeed, so it stays out of the queue instead of getting stuck there forever). The event is still recorded locally and the chain stays valid — `"failed"` is just operational metadata.
289
+
290
+ In `mode="hybrid"`, the event is appended to the local JSONL **once**, already carrying its final receipt (an O(1) append per event, not a full-file rewrite). A network failure still records the event locally (as `pending`/`failed`), so it is never lost to a failed delivery; the only loss window is the process being killed mid-request, and even then a delivery that did reach ImmutableLog is preserved in the remote store.
285
291
 
286
292
  ### Integrity guarantee
287
293
 
@@ -333,7 +339,7 @@ Records an event and returns the full event (already with `id`, `timestamp`, `ha
333
339
  | `hash` | `str` | SHA-256 (64 hex chars) of the event + `previous_hash` |
334
340
  | `severity` | `str \| absent` | Only present if passed to `log()`. Becomes `meta.type` on ImmutableLog |
335
341
  | `immutable_trail` | `str \| absent` | Only present if passed to `log()` (and not empty after sanitization). Becomes `meta.immutable_trail` |
336
- | `immutablelog` | `dict \| absent` | Only present in `mode="remote"`/`"hybrid"`. `status` is `"delivered"` or `"pending"`; other fields (`tx_id`, `payload_hash`, `duplicate`, `request_id`, ...) come from the ImmutableLog response |
342
+ | `immutablelog` | `dict \| absent` | Only present in `mode="remote"`/`"hybrid"`. `status` is `"delivered"`, `"pending"` (transient failure, queued), or `"failed"` (permanent failure, not queued); other fields (`tx_id`, `payload_hash`, `duplicate`, `request_id`, ...) come from the ImmutableLog response |
337
343
 
338
344
  In `mode="remote"`, a permanent failure or exhausted retries raise `RuntimeError` instead of returning the dict.
339
345
 
@@ -356,10 +362,13 @@ Hash of the last recorded event (in-memory cache, O(1)) — `None` if no event h
356
362
  Attempts to redeliver to ImmutableLog every event marked as `pending` (recorded in `audit.pending.jsonl`, derived from `file_path`). Only relevant in `mode="hybrid"` — other modes never populate this queue.
357
363
 
358
364
  ```python
359
- {"flushed": 1, "still_pending": 0, "total": 1}
365
+ {"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
360
366
  ```
361
367
 
362
- On success, updates `event["immutablelog"]` in `audit.jsonl` (without changing `hash`) and removes the event from the queue. Events that fail again stay in the queue for the next call.
368
+ For each queued event:
369
+ - **delivered** → updates `event["immutablelog"]` in `audit.jsonl` to `"delivered"` (without changing `hash`) and removes it from the queue (counts toward `flushed`);
370
+ - **permanent failure** → marks it `"failed"` in `audit.jsonl` and removes it from the queue, so it doesn't stay stuck forever (counts toward `failed`);
371
+ - **retryable failure** → left in the queue for the next call (counts toward `still_pending`).
363
372
 
364
373
  ---
365
374
 
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "rust-py-audit"
7
- version = "0.2.3"
7
+ version = "0.3.0"
8
8
  description = "Fast, tamper-evident event auditing (hash chain) for Python apps, with a Rust core."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -8,5 +8,5 @@ que existe um módulo nativo por trás.
8
8
 
9
9
  from ._rust_py_audit import AuditLogger, hello
10
10
 
11
- __version__ = "0.2.3"
11
+ __version__ = "0.3.0"
12
12
  __all__ = ["AuditLogger", "hello"]
@@ -354,38 +354,54 @@ impl AuditLogger {
354
354
 
355
355
  // Igual a `log()`: tudo que trava o `Mutex` ou faz rede roda com
356
356
  // o GIL liberado (REGRA CRÍTICA na doc da struct).
357
- let (flushed, still_pending) = py.allow_threads(|| -> Result<(usize, usize), crate::errors::AuditError> {
358
- // Primeiro a parte cara/lenta (rede), SEM segurar o lock:
359
- // acumulamos os receipts entregues e os ids a remover da fila.
360
- let mut delivered: HashMap<String, ImmutableLogReceipt> = HashMap::new();
361
- let mut flushed_ids: HashSet<String> = HashSet::new();
362
-
363
- for event in &pending_events {
364
- let severity = resolve_severity(event.severity.as_deref())
365
- .map_err(crate::errors::AuditError::Config)?;
366
- if let Ok(receipt) = self.deliver(event, severity, event.immutable_trail.as_deref())
367
- {
368
- delivered.insert(event.id.clone(), receipt);
369
- flushed_ids.insert(event.id.clone());
357
+ let (flushed, failed, still_pending) =
358
+ py.allow_threads(|| -> Result<(usize, usize, usize), crate::errors::AuditError> {
359
+ // Primeiro a parte cara/lenta (rede), SEM segurar o lock:
360
+ // acumulamos os receipts a gravar e os ids a remover da fila.
361
+ // `receipts` junta entregues e permanentes-falhados (ambos
362
+ // saem da fila); `handled` é o conjunto de ids a remover.
363
+ let mut receipts: HashMap<String, ImmutableLogReceipt> = HashMap::new();
364
+ let mut handled: HashSet<String> = HashSet::new();
365
+ let mut flushed = 0usize;
366
+ let mut failed = 0usize;
367
+
368
+ for event in &pending_events {
369
+ let severity = resolve_severity(event.severity.as_deref())
370
+ .map_err(crate::errors::AuditError::Config)?;
371
+ match self.deliver(event, severity, event.immutable_trail.as_deref()) {
372
+ Ok(receipt) => {
373
+ receipts.insert(event.id.clone(), receipt);
374
+ handled.insert(event.id.clone());
375
+ flushed += 1;
376
+ }
377
+ Err(err) if err.is_retryable() => {
378
+ // Transitório: continua na fila para a próxima
379
+ // chamada de `flush_pending()` tentar de novo.
380
+ }
381
+ Err(_) => {
382
+ // Permanente: marca `failed` e tira da fila —
383
+ // retentar não muda o resultado (#8).
384
+ receipts.insert(event.id.clone(), ImmutableLogReceipt::failed());
385
+ handled.insert(event.id.clone());
386
+ failed += 1;
387
+ }
388
+ }
370
389
  }
371
- // Em falha o evento simplesmente continua na fila para a
372
- // próxima chamada de `flush_pending()` tentar de novo.
373
- }
374
390
 
375
- // Só agora travamos, para a parte que mexe nos arquivos —
376
- // serializando contra um `log()` concorrente. As funções em
377
- // lote releem os arquivos aqui dentro, então eventos
378
- // acrescentados nesse meio-tempo são preservados.
379
- let _guard = self.lock_last_hash();
380
- storage::update_event_receipts(&self.file_path, &delivered)?;
381
- storage::remove_events(&pending_path, &flushed_ids)?;
391
+ // Só agora travamos, para a parte que mexe nos arquivos —
392
+ // serializando contra um `log()` concorrente. As funções em
393
+ // lote releem os arquivos aqui dentro, então eventos
394
+ // acrescentados nesse meio-tempo são preservados.
395
+ let _guard = self.lock_last_hash();
396
+ storage::update_event_receipts(&self.file_path, &receipts)?;
397
+ storage::remove_events(&pending_path, &handled)?;
382
398
 
383
- let flushed = flushed_ids.len();
384
- Ok((flushed, total - flushed))
385
- })?;
399
+ Ok((flushed, failed, total - flushed - failed))
400
+ })?;
386
401
 
387
402
  let dict = PyDict::new_bound(py);
388
403
  dict.set_item("flushed", flushed)?;
404
+ dict.set_item("failed", failed)?;
389
405
  dict.set_item("still_pending", still_pending)?;
390
406
  dict.set_item("total", total)?;
391
407
  Ok(dict.into())
@@ -499,29 +515,47 @@ impl AuditLogger {
499
515
  *guard = Some(event.hash.clone());
500
516
  }
501
517
  AuditMode::Hybrid => {
502
- // Salva local PRIMEIRO: mesmo que o processo morra antes
503
- // do envio terminar, o evento já está persistido (sem
504
- // receipt) — nunca perdemos o registro local por uma
505
- // falha de rede.
506
- storage::append_event(&self.file_path, &event)?;
507
- *guard = Some(event.hash.clone());
518
+ // Entrega ANTES de gravar, para anexar o evento ao JSONL
519
+ // local UMA única vez (append O(1)) já com o receipt
520
+ // final — em vez de gravar e depois reescrever o arquivo
521
+ // inteiro (O(n)) só para anexar o receipt. Falha em hybrid
522
+ // NUNCA é exceção Python.
523
+ //
524
+ // Isso preserva a garantia "uma falha de REDE nunca perde
525
+ // o registro local": uma falha de rede (timeout, conexão
526
+ // recusada, 5xx) cai num dos ramos `Err` abaixo, que grava
527
+ // o evento localmente do mesmo jeito. O único cenário novo
528
+ // de perda é o processo ser MORTO no meio da chamada de
529
+ // rede (janela limitada por `timeout_ms`) — e, mesmo aí, se
530
+ // a entrega chegou ao ImmutableLog o registro existe no
531
+ // destino remoto (a fonte de verdade no modo hybrid).
532
+ let (receipt, queue_for_retry) =
533
+ match self.deliver(&event, resolved_severity, event.immutable_trail.as_deref()) {
534
+ Ok(receipt) => (receipt, false),
535
+ Err(err) if err.is_retryable() => {
536
+ // Transitório: vira `pending` e entra na fila
537
+ // para `flush_pending()` tentar de novo depois.
538
+ (ImmutableLogReceipt::pending(), true)
539
+ }
540
+ Err(_) => {
541
+ // Permanente (400/401/403/429/...): retentar não
542
+ // muda nada, então `failed` e NÃO enfileira.
543
+ (ImmutableLogReceipt::failed(), false)
544
+ }
545
+ };
508
546
 
509
- match self.deliver(&event, resolved_severity, event.immutable_trail.as_deref()) {
510
- Ok(receipt) => {
511
- event.immutablelog = Some(receipt.clone());
512
- storage::update_event_receipt(&self.file_path, &event.id, &receipt)?;
513
- }
514
- Err(_) => {
515
- // Falha em hybrid NÃO é exceção Python: o evento
516
- // já está seguro localmente, então devolvemos
517
- // status "pending" e deixamos `flush_pending()`
518
- // tentar de novo depois.
519
- let pending_receipt = ImmutableLogReceipt::pending();
520
- event.immutablelog = Some(pending_receipt.clone());
521
- storage::update_event_receipt(&self.file_path, &event.id, &pending_receipt)?;
522
- storage::append_event(&storage::pending_path_for(&self.file_path), &event)?;
523
- }
547
+ event.immutablelog = Some(receipt);
548
+
549
+ // Enfileira ANTES de gravar no arquivo principal: se o
550
+ // processo morrer entre os dois appends, o evento ainda
551
+ // está na fila e `flush_pending()` o reentrega — preferível
552
+ // a ficar gravado como `pending` no arquivo principal sem
553
+ // estar na fila (preso sem reenvio).
554
+ if queue_for_retry {
555
+ storage::append_event(&storage::pending_path_for(&self.file_path), &event)?;
524
556
  }
557
+ storage::append_event(&self.file_path, &event)?;
558
+ *guard = Some(event.hash.clone());
525
559
  }
526
560
  }
527
561
 
@@ -39,4 +39,16 @@ impl ImmutableLogReceipt {
39
39
  ..Default::default()
40
40
  }
41
41
  }
42
+
43
+ /// Entrega abandonada por erro PERMANENTE (400/401/403/429/...):
44
+ /// diferente de `pending`, um evento `failed` NÃO entra na fila de
45
+ /// reenvio — retentar não mudaria o resultado, então deixá-lo
46
+ /// pendente só o prenderia para sempre numa fila que
47
+ /// `flush_pending()` nunca conseguiria esvaziar.
48
+ pub fn failed() -> Self {
49
+ ImmutableLogReceipt {
50
+ status: "failed".to_string(),
51
+ ..Default::default()
52
+ }
53
+ }
42
54
  }
@@ -14,7 +14,7 @@ def test_flush_pending_on_empty_queue_is_a_no_op(tmp_path, immutablelog_server):
14
14
 
15
15
  result = audit.flush_pending()
16
16
 
17
- assert result == {"flushed": 0, "still_pending": 0, "total": 0}
17
+ assert result == {"flushed": 0, "failed": 0, "still_pending": 0, "total": 0}
18
18
 
19
19
 
20
20
  def test_flush_pending_retries_and_updates_receipt_on_recovery(tmp_path, immutablelog_server):
@@ -32,7 +32,7 @@ def test_flush_pending_retries_and_updates_receipt_on_recovery(tmp_path, immutab
32
32
  assert event["immutablelog"]["status"] == "pending"
33
33
 
34
34
  still_failing = audit.flush_pending()
35
- assert still_failing == {"flushed": 0, "still_pending": 1, "total": 1}
35
+ assert still_failing == {"flushed": 0, "failed": 0, "still_pending": 1, "total": 1}
36
36
 
37
37
  immutablelog_server.set_default_response(202, {
38
38
  "ok": True,
@@ -43,7 +43,7 @@ def test_flush_pending_retries_and_updates_receipt_on_recovery(tmp_path, immutab
43
43
  "request_id": "req_flushed",
44
44
  })
45
45
  recovered = audit.flush_pending()
46
- assert recovered == {"flushed": 1, "still_pending": 0, "total": 1}
46
+ assert recovered == {"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
47
47
 
48
48
  pending_path = tmp_path / "audit.pending.jsonl"
49
49
  assert pending_path.read_text().strip() == ""
@@ -123,4 +123,37 @@ def test_flush_pending_without_pending_file_is_a_no_op(tmp_path):
123
123
 
124
124
  result = audit.flush_pending()
125
125
 
126
- assert result == {"flushed": 0, "still_pending": 0, "total": 0}
126
+ assert result == {"flushed": 0, "failed": 0, "still_pending": 0, "total": 0}
127
+
128
+
129
+ def test_flush_pending_gives_up_on_permanent_error_and_dequeues(tmp_path, immutablelog_server):
130
+ # Um evento que ficou "pending" por uma falha transitória (500), mas
131
+ # cuja causa virou PERMANENTE (401) na hora do flush, precisa sair da
132
+ # fila marcado como "failed" — em vez de ficar preso para sempre (#8).
133
+ immutablelog_server.set_default_response(500, {"ok": False, "reason": "internal_error"})
134
+ audit = AuditLogger(
135
+ app_name="billing-api",
136
+ file_path=str(tmp_path / "audit.jsonl"),
137
+ mode="hybrid",
138
+ immutablelog_url=immutablelog_server.url,
139
+ immutablelog_api_key="iml_live_ok",
140
+ retry_enabled=False,
141
+ )
142
+
143
+ event = audit.log(actor_id="user_1", action="DELETE_INVOICE", resource="invoice", resource_id="inv_1")
144
+ assert event["immutablelog"]["status"] == "pending"
145
+
146
+ immutablelog_server.set_default_response(401, {"ok": False, "reason": "invalid_api_key"})
147
+ result = audit.flush_pending()
148
+ assert result == {"flushed": 0, "failed": 1, "still_pending": 0, "total": 1}
149
+
150
+ # Saiu da fila e ficou marcado como "failed" no arquivo principal.
151
+ pending_path = tmp_path / "audit.pending.jsonl"
152
+ assert pending_path.read_text().strip() == ""
153
+
154
+ persisted = json.loads((tmp_path / "audit.jsonl").read_text().strip())
155
+ assert persisted["immutablelog"]["status"] == "failed"
156
+ assert persisted["hash"] == event["hash"]
157
+
158
+ # Uma nova chamada não tem mais nada para tentar.
159
+ assert audit.flush_pending() == {"flushed": 0, "failed": 0, "still_pending": 0, "total": 0}
@@ -39,9 +39,10 @@ def test_hybrid_does_not_raise_on_remote_failure(tmp_path, immutablelog_server):
39
39
  assert event["immutablelog"]["status"] == "pending"
40
40
 
41
41
 
42
- def test_hybrid_failure_keeps_local_event_and_queues_pending_file(tmp_path, immutablelog_server):
43
- immutablelog_server.set_default_response(401, {"ok": False, "reason": "invalid_api_key"})
44
- audit = _audit_logger(tmp_path, immutablelog_server)
42
+ def test_hybrid_retryable_failure_keeps_local_event_and_queues_pending_file(tmp_path, immutablelog_server):
43
+ # 500 é RETRYABLE: vira "pending" e entra na fila para reenvio.
44
+ immutablelog_server.set_default_response(500, {"ok": False, "reason": "internal_error"})
45
+ audit = _audit_logger(tmp_path, immutablelog_server, retry_enabled=False)
45
46
 
46
47
  event = audit.log(actor_id="user_1", action="DELETE_INVOICE", resource="invoice", resource_id="inv_1")
47
48
 
@@ -58,6 +59,30 @@ def test_hybrid_failure_keeps_local_event_and_queues_pending_file(tmp_path, immu
58
59
  assert json.loads(pending_lines[0])["id"] == event["id"]
59
60
 
60
61
 
62
+ def test_hybrid_permanent_failure_marks_failed_and_does_not_queue(tmp_path, immutablelog_server):
63
+ # 401 é PERMANENTE (#8): vira "failed" e NÃO entra na fila — retentar
64
+ # não mudaria o resultado, então não fica preso para sempre.
65
+ immutablelog_server.set_default_response(401, {"ok": False, "reason": "invalid_api_key"})
66
+ audit = _audit_logger(tmp_path, immutablelog_server)
67
+
68
+ event = audit.log(actor_id="user_1", action="DELETE_INVOICE", resource="invoice", resource_id="inv_1")
69
+
70
+ assert event["immutablelog"]["status"] == "failed"
71
+
72
+ audit_lines = (tmp_path / "audit.jsonl").read_text().strip().splitlines()
73
+ assert len(audit_lines) == 1
74
+ persisted = json.loads(audit_lines[0])
75
+ assert persisted["hash"] == event["hash"]
76
+ assert persisted["immutablelog"]["status"] == "failed"
77
+
78
+ # Erro permanente não cria/popula a fila de pendências.
79
+ pending_path = tmp_path / "audit.pending.jsonl"
80
+ assert not pending_path.exists() or pending_path.read_text().strip() == ""
81
+
82
+ # E a cadeia local continua válida (o "failed" é só metadado).
83
+ assert audit.verify()["valid"] is True
84
+
85
+
61
86
  def test_hybrid_local_chain_stays_valid_after_failure(tmp_path, immutablelog_server):
62
87
  immutablelog_server.set_default_response(500, {"ok": False, "reason": "internal_error"})
63
88
  audit = _audit_logger(tmp_path, immutablelog_server, retry_enabled=False)
File without changes
File without changes
File without changes
File without changes