rust-py-audit 0.2.4__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.4 → rust_py_audit-0.3.0}/Cargo.lock +1 -1
  2. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/Cargo.toml +1 -1
  3. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/PKG-INFO +16 -8
  4. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/README.md +15 -7
  5. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/pyproject.toml +1 -1
  6. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/python/rust_py_audit/__init__.py +1 -1
  7. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/src/audit_logger.rs +81 -47
  8. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/src/immutablelog_receipt.rs +12 -0
  9. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/tests/test_flush_pending.py +37 -4
  10. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/tests/test_immutablelog_hybrid.py +28 -3
  11. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/.github/workflows/release.yml +0 -0
  12. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/.gitignore +0 -0
  13. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/LICENSE +0 -0
  14. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/examples/__init__.py +0 -0
  15. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/examples/basic_usage.py +0 -0
  16. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/examples/django_example/__init__.py +0 -0
  17. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/examples/django_example/settings.py +0 -0
  18. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/examples/django_example/urls.py +0 -0
  19. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/examples/django_example/views.py +0 -0
  20. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/examples/fastapi_app.py +0 -0
  21. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/examples/immutablelog_basic.py +0 -0
  22. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/python/rust_py_audit/django.py +0 -0
  23. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/python/rust_py_audit/fastapi.py +0 -0
  24. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/src/errors.rs +0 -0
  25. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/src/event.rs +0 -0
  26. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/src/hash.rs +0 -0
  27. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/src/immutablelog_client.rs +0 -0
  28. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/src/immutablelog_config.rs +0 -0
  29. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/src/lib.rs +0 -0
  30. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/src/pyconv.rs +0 -0
  31. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/src/retry.rs +0 -0
  32. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/src/storage.rs +0 -0
  33. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/src/verifier.rs +0 -0
  34. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/tests/conftest.py +0 -0
  35. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/tests/test_audit_logger.py +0 -0
  36. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/tests/test_concurrency.py +0 -0
  37. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/tests/test_django_immutablelog.py +0 -0
  38. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/tests/test_django_middleware.py +0 -0
  39. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/tests/test_fastapi_immutablelog.py +0 -0
  40. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/tests/test_fastapi_middleware.py +0 -0
  41. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/tests/test_hello.py +0 -0
  42. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/tests/test_immutablelog_remote.py +0 -0
  43. {rust_py_audit-0.2.4 → rust_py_audit-0.3.0}/tests/test_metadata_bigint.py +0 -0
  44. {rust_py_audit-0.2.4 → 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.4"
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.4"
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.4
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
@@ -198,7 +198,7 @@ See the full example in [`examples/django_example/`](examples/django_example/).
198
198
  |---|---|---|---|
199
199
  | `"local"` (default) | ✅ | ❌ | The library's original behavior, no external dependency |
200
200
  | `"remote"` | ❌ (except for pending entries, see below) | ✅ | ImmutableLog is the single source of truth; a delivery failure raises an exception |
201
- | `"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) |
202
202
 
203
203
  `mode="local"` is the default — existing code calling `AuditLogger(app_name, file_path)` keeps working unchanged.
204
204
 
@@ -228,11 +228,12 @@ event = audit.log(
228
228
 
229
229
  print(event["immutablelog"])
230
230
  # {"status": "delivered", "tx_id": "tx_...", "payload_hash": "...", ...}
231
- # 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)
232
233
 
233
234
  # Retries delivery of every event still marked "pending":
234
235
  print(audit.flush_pending())
235
- # {"flushed": 1, "still_pending": 0, "total": 1}
236
+ # {"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
236
237
  ```
237
238
 
238
239
  ### Environment variables
@@ -319,7 +320,11 @@ Same behavior as FastAPI: `severity` computed from `status_code`, and delivery f
319
320
  - **Retryable**: `5xx` and timeouts.
320
321
  - **Permanent** (never retried): `400`, `401`, `403`, `429`, and any other client error.
321
322
  - In `mode="remote"`, exhausting retries (or a permanent error) raises `RuntimeError`.
322
- - 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.
323
328
 
324
329
  ### Integrity guarantee
325
330
 
@@ -371,7 +376,7 @@ Records an event and returns the full event (already with `id`, `timestamp`, `ha
371
376
  | `hash` | `str` | SHA-256 (64 hex chars) of the event + `previous_hash` |
372
377
  | `severity` | `str \| absent` | Only present if passed to `log()`. Becomes `meta.type` on ImmutableLog |
373
378
  | `immutable_trail` | `str \| absent` | Only present if passed to `log()` (and not empty after sanitization). Becomes `meta.immutable_trail` |
374
- | `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 |
375
380
 
376
381
  In `mode="remote"`, a permanent failure or exhausted retries raise `RuntimeError` instead of returning the dict.
377
382
 
@@ -394,10 +399,13 @@ Hash of the last recorded event (in-memory cache, O(1)) — `None` if no event h
394
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.
395
400
 
396
401
  ```python
397
- {"flushed": 1, "still_pending": 0, "total": 1}
402
+ {"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
398
403
  ```
399
404
 
400
- 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`).
401
409
 
402
410
  ---
403
411
 
@@ -161,7 +161,7 @@ See the full example in [`examples/django_example/`](examples/django_example/).
161
161
  |---|---|---|---|
162
162
  | `"local"` (default) | ✅ | ❌ | The library's original behavior, no external dependency |
163
163
  | `"remote"` | ❌ (except for pending entries, see below) | ✅ | ImmutableLog is the single source of truth; a delivery failure raises an exception |
164
- | `"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) |
165
165
 
166
166
  `mode="local"` is the default — existing code calling `AuditLogger(app_name, file_path)` keeps working unchanged.
167
167
 
@@ -191,11 +191,12 @@ event = audit.log(
191
191
 
192
192
  print(event["immutablelog"])
193
193
  # {"status": "delivered", "tx_id": "tx_...", "payload_hash": "...", ...}
194
- # 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)
195
196
 
196
197
  # Retries delivery of every event still marked "pending":
197
198
  print(audit.flush_pending())
198
- # {"flushed": 1, "still_pending": 0, "total": 1}
199
+ # {"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
199
200
  ```
200
201
 
201
202
  ### Environment variables
@@ -282,7 +283,11 @@ Same behavior as FastAPI: `severity` computed from `status_code`, and delivery f
282
283
  - **Retryable**: `5xx` and timeouts.
283
284
  - **Permanent** (never retried): `400`, `401`, `403`, `429`, and any other client error.
284
285
  - In `mode="remote"`, exhausting retries (or a permanent error) raises `RuntimeError`.
285
- - 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.
286
291
 
287
292
  ### Integrity guarantee
288
293
 
@@ -334,7 +339,7 @@ Records an event and returns the full event (already with `id`, `timestamp`, `ha
334
339
  | `hash` | `str` | SHA-256 (64 hex chars) of the event + `previous_hash` |
335
340
  | `severity` | `str \| absent` | Only present if passed to `log()`. Becomes `meta.type` on ImmutableLog |
336
341
  | `immutable_trail` | `str \| absent` | Only present if passed to `log()` (and not empty after sanitization). Becomes `meta.immutable_trail` |
337
- | `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 |
338
343
 
339
344
  In `mode="remote"`, a permanent failure or exhausted retries raise `RuntimeError` instead of returning the dict.
340
345
 
@@ -357,10 +362,13 @@ Hash of the last recorded event (in-memory cache, O(1)) — `None` if no event h
357
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.
358
363
 
359
364
  ```python
360
- {"flushed": 1, "still_pending": 0, "total": 1}
365
+ {"flushed": 1, "failed": 0, "still_pending": 0, "total": 1}
361
366
  ```
362
367
 
363
- 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`).
364
372
 
365
373
  ---
366
374
 
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "rust-py-audit"
7
- version = "0.2.4"
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.4"
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