feat: make mobile issue capture idempotent (#177)
This commit is contained in:
1 parent
9293dde1af
commit
6f54d5764b
5 files changed
+316
-19
No files matched your search
+53
-3
@@ -6,7 +6,7 @@ from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Path as PathParam, Query
|
||||
from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
|
||||
@@ -55,6 +55,8 @@ EVENT_STREAM_TIMEOUT_SECONDS = 5.0
|
||||
READINESS_TIMEOUT_SECONDS = 5.0
|
||||
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
|
||||
ISSUE_ACTION_TIMEOUT_SECONDS = 5.0
|
||||
ISSUE_CREATION_IDEMPOTENCY_TTL_SECONDS = 600.0
|
||||
ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES = 256
|
||||
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
|
||||
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
|
||||
WORK_PAGE_TIMEOUT_SECONDS = 5.0
|
||||
@@ -80,6 +82,9 @@ _live_section_retry_at: dict[str, float | None] = {
|
||||
}
|
||||
_live_snapshot_refreshing_sections: set[str] = set()
|
||||
_read_notification_ids: set[int] = set()
|
||||
_issue_creation_operations: dict[
|
||||
str, tuple[tuple[Any, ...], asyncio.Task, float]
|
||||
] = {}
|
||||
|
||||
|
||||
class ContextPayloadError(ValueError):
|
||||
@@ -952,7 +957,12 @@ async def repository_labels(owner: str, repo: str):
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/issues", status_code=201)
|
||||
async def create_assigned_issue(creation: IssueCreation, owner: str, repo: str):
|
||||
async def create_assigned_issue(
|
||||
creation: IssueCreation,
|
||||
owner: str,
|
||||
repo: str,
|
||||
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def create_issue():
|
||||
@@ -980,8 +990,48 @@ async def create_assigned_issue(creation: IssueCreation, owner: str, repo: str):
|
||||
repository, creation.title, creation.body, login, creation.label_ids
|
||||
)
|
||||
|
||||
operation = create_issue()
|
||||
if idempotency_key:
|
||||
now = time.monotonic()
|
||||
expired = [
|
||||
key for key, (_, task, created_at) in _issue_creation_operations.items()
|
||||
if task.done()
|
||||
and now - created_at >= ISSUE_CREATION_IDEMPOTENCY_TTL_SECONDS
|
||||
]
|
||||
for key in expired:
|
||||
_issue_creation_operations.pop(key, None)
|
||||
fingerprint = (
|
||||
repository, creation.title, creation.body, tuple(creation.label_ids)
|
||||
)
|
||||
existing = _issue_creation_operations.get(idempotency_key)
|
||||
if existing is not None:
|
||||
operation.close()
|
||||
if existing[0] != fingerprint:
|
||||
raise HTTPException(status_code=409, detail="Idempotency key already used")
|
||||
task = existing[1]
|
||||
else:
|
||||
while len(_issue_creation_operations) >= ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES:
|
||||
completed = [
|
||||
key for key, (_, task, _) in _issue_creation_operations.items()
|
||||
if task.done()
|
||||
]
|
||||
if not completed:
|
||||
operation.close()
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Issue creation is busy; please retry",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
oldest = min(
|
||||
completed, key=lambda key: _issue_creation_operations[key][2]
|
||||
)
|
||||
_issue_creation_operations.pop(oldest)
|
||||
task = asyncio.create_task(operation)
|
||||
_issue_creation_operations[idempotency_key] = (fingerprint, task, now)
|
||||
operation = asyncio.shield(task)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(create_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
|
||||
result = await asyncio.wait_for(operation, timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
|
||||
Reference in new issue
Block a user