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.
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/Cargo.lock +1 -1
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/Cargo.toml +1 -1
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/PKG-INFO +17 -8
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/README.md +16 -7
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/pyproject.toml +1 -1
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/python/rust_py_audit/__init__.py +1 -1
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/audit_logger.rs +81 -47
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/immutablelog_receipt.rs +12 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_flush_pending.py +37 -4
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_immutablelog_hybrid.py +28 -3
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/.github/workflows/release.yml +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/.gitignore +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/LICENSE +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/examples/__init__.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/examples/basic_usage.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/examples/django_example/__init__.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/examples/django_example/settings.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/examples/django_example/urls.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/examples/django_example/views.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/examples/fastapi_app.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/examples/immutablelog_basic.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/python/rust_py_audit/django.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/python/rust_py_audit/fastapi.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/errors.rs +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/event.rs +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/hash.rs +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/immutablelog_client.rs +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/immutablelog_config.rs +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/lib.rs +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/pyconv.rs +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/retry.rs +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/storage.rs +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/src/verifier.rs +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/conftest.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_audit_logger.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_concurrency.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_django_immutablelog.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_django_middleware.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_fastapi_immutablelog.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_fastapi_middleware.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_hello.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_immutablelog_remote.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_metadata_bigint.py +0 -0
- {rust_py_audit-0.2.3 → rust_py_audit-0.3.0}/tests/test_verify.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rust-py-audit
|
|
3
|
-
Version: 0.
|
|
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 —
|
|
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, ...}
|
|
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"`,
|
|
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 `"
|
|
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
|
-
|
|
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 —
|
|
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, ...}
|
|
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"`,
|
|
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 `"
|
|
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
|
-
|
|
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
|
|
|
@@ -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) =
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
let
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|