Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.

Commit 3ead8fa

Browse files
committed
Handle grant webhook and retry failure edges
1 parent c85e9a1 commit 3ead8fa

3 files changed

Lines changed: 60 additions & 1 deletion

File tree

src/api/routes/billing.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,9 @@ async def razorpay_webhook(request: Request) -> dict[str, str]:
341341
elif package_id in billing_config.TOP_UP_PACKS:
342342
logger.warning("Razorpay top-up webhook missing order id: %s", event_name)
343343
raise HTTPException(status_code=400, detail="Webhook order id is required for credit grant")
344+
else:
345+
logger.warning("Razorpay webhook has unknown grant package id: %s", package_id)
346+
raise HTTPException(status_code=400, detail="Webhook package id is not configured for credit grant")
344347

345348
first_seen = await asyncio.to_thread(
346349
service.store.mark_payment_event,

src/api/routes/v2/jobs.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,15 @@ async def retry_job(job_id: str, request: Request, user: dict = Depends(require_
141141
except InsufficientCredits as exc:
142142
return _error(request, str(exc), 402, elapsed_ms(start))
143143
except Exception as exc:
144+
release_error = None
144145
if billing_reservation_created and billing_account_id:
145-
await asyncio.to_thread(release_job_reservation, billing_account_id, job_id)
146+
try:
147+
await asyncio.to_thread(release_job_reservation, billing_account_id, job_id)
148+
except Exception as release_exc:
149+
release_error = str(release_exc) or release_exc.__class__.__name__
146150
error = str(exc) or exc.__class__.__name__
151+
if release_error:
152+
error = f"{error}; billing reservation release failed: {release_error}"
147153
await asyncio.to_thread(get_default_job_store().mark_failed, job_id, error)
148154
return _error(request, f"Retry failed to start workflow: {error}", 503, elapsed_ms(start))
149155
job = await asyncio.to_thread(get_default_job_store().get, job_id)

tests/api/test_memory_versioning.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,56 @@ def fail_update_payload(job_id, payload):
303303
assert store.jobs["job-1"]["error"] == "payload write failed"
304304

305305

306+
def test_v2_retry_release_failure_still_marks_job_failed(monkeypatch):
307+
app, _ = _build_app(monkeypatch)
308+
store = FakeJobStore()
309+
store.jobs["job-1"] = {
310+
"job_id": "job-1",
311+
"job_type": "memory_ingest",
312+
"payload": {"billing_account_id": "acct-1", "user_id": "hunter"},
313+
"user_id": "hunter",
314+
"status": "failed",
315+
"timeout_seconds": 30,
316+
"max_attempts": 3,
317+
"retry_count": 1,
318+
"attempt_count": 1,
319+
"workflow_id": "old-workflow",
320+
}
321+
322+
class FakeEstimate:
323+
reserved_credits = 100
324+
325+
def model_dump(self):
326+
return {"reserved_credits": self.reserved_credits}
327+
328+
class FakeBillingService:
329+
def estimate_required_credits(self, job_type, payload):
330+
return FakeEstimate()
331+
332+
def reserve_credits(self, account_id, job_id, estimated_credits):
333+
return SimpleNamespace(reservation_id="reservation-1", created=True)
334+
335+
async def fake_start_job_workflow(job):
336+
raise RuntimeError("temporal unavailable")
337+
338+
def fail_release(account_id, job_id):
339+
raise RuntimeError("mongo unavailable")
340+
341+
monkeypatch.setattr(jobs_v2, "get_default_job_store", lambda: store)
342+
monkeypatch.setattr(durable, "get_default_job_store", lambda: store)
343+
monkeypatch.setattr(jobs_v2, "get_default_billing_service", lambda: FakeBillingService())
344+
monkeypatch.setattr(jobs_v2, "release_job_reservation", fail_release)
345+
monkeypatch.setattr(jobs_v2, "start_job_workflow", fake_start_job_workflow)
346+
347+
response = TestClient(app).post("/v2/jobs/job-1/retry")
348+
349+
assert response.status_code == 503
350+
assert store.jobs["job-1"]["status"] == "failed"
351+
assert store.jobs["job-1"]["error"] == (
352+
"temporal unavailable; billing reservation release failed: mongo unavailable"
353+
)
354+
355+
306356
def test_v2_retry_start_failure_keeps_reused_billing_reservation(monkeypatch):
307357
app, _ = _build_app(monkeypatch)
308358
store = FakeJobStore()

0 commit comments

Comments
 (0)