Skip to content

Commit 0e361a2

Browse files
authored
Merge pull request #575 from abhishek-8081/issue-543-fix
2 parents 9b23056 + ae8d8a1 commit 0e361a2

8 files changed

Lines changed: 228 additions & 22 deletions

File tree

app/api/routes/forms.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def fill_form(form: FormFill, db: Session = Depends(get_db)):
2323

2424
fetched_template = get_template(db, form.template_id)
2525
if not fetched_template:
26-
raise AppError("Template not found", status_code=404)
26+
raise AppError("Template not found", status_code=404, error_code="TEMPLATE_NOT_FOUND")
2727

2828
controller = Controller()
2929
try:
@@ -40,7 +40,7 @@ def fill_form(form: FormFill, db: Session = Depends(get_db)):
4040
)
4141
return create_form(db, submission)
4242
except Exception as e:
43-
raise AppError(str(e), status_code=500)
43+
raise AppError(str(e), status_code=500, error_code="FORM_FILL_ERROR")
4444

4545

4646
@router.get("/models", response_model=ModelsResponse)
@@ -93,9 +93,10 @@ def transcribe(audio: UploadFile = File(...)):
9393
f"Could not connect to the speech-to-text service at {whisper_url}. "
9494
"Please ensure the whisper service is running.",
9595
status_code=503,
96+
error_code="STT_UNAVAILABLE",
9697
)
9798
except requests.exceptions.RequestException as e:
98-
raise AppError(f"Transcription failed: {e}", status_code=502)
99+
raise AppError(f"Transcription failed: {e}", status_code=502, error_code="TRANSCRIPTION_FAILED")
99100

100101
try:
101102
text = (response.json().get("text") or "").strip()

app/api/schemas/common.py

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,55 @@
1-
from pydantic import BaseModel
1+
from __future__ import annotations
2+
3+
from datetime import datetime
24
from typing import Any
35

4-
class SuccessResponse(BaseModel):
5-
success: bool = True
6-
data: Any
6+
from pydantic import BaseModel, ConfigDict
7+
8+
from app.api.schemas.enums import JobStatus, JobType
9+
10+
11+
class ValidationErrorItem(BaseModel):
12+
model_config = ConfigDict()
13+
field: str | None = None
14+
issue: str | None = None
15+
value: Any = None
716

8-
class ErrorDetail(BaseModel):
9-
code: str
10-
message: str
1117

1218
class ErrorResponse(BaseModel):
13-
success: bool = False
14-
error: ErrorDetail
19+
model_config = ConfigDict()
20+
error_code: str
21+
message: str
22+
detail: dict[str, Any] | None = None
23+
retry_after_seconds: int | None = None
24+
validation_errors: list[ValidationErrorItem] | None = None
25+
26+
27+
class Pagination(BaseModel):
28+
model_config = ConfigDict()
29+
total: int
30+
page: int
31+
per_page: int
32+
total_pages: int
33+
has_next: bool
34+
has_prev: bool
35+
36+
37+
class AsyncJobResponse(BaseModel):
38+
model_config = ConfigDict()
39+
job_id: str
40+
job_type: JobType | None = None
41+
status: JobStatus
42+
estimated_seconds: int | None = None
43+
poll_url: str | None = None
44+
45+
46+
class Job(BaseModel):
47+
model_config = ConfigDict()
48+
job_id: str
49+
job_type: JobType
50+
status: JobStatus
51+
progress_percent: int | None = None
52+
result_url: str | None = None
53+
error: ErrorResponse | None = None
54+
created_at: datetime | None = None
55+
updated_at: datetime | None = None

app/api/schemas/enums.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
from enum import Enum
2+
3+
4+
class InputStatus(str, Enum):
5+
queued = "queued"
6+
transcribing = "transcribing"
7+
ready = "ready"
8+
failed = "failed"
9+
10+
11+
class ExtractionStatus(str, Enum):
12+
processing = "processing"
13+
completed = "completed"
14+
failed = "failed"
15+
needs_review = "needs_review"
16+
17+
18+
class FormStatus(str, Enum):
19+
queued = "queued"
20+
generating = "generating"
21+
completed = "completed"
22+
failed = "failed"
23+
24+
25+
class ReportStatus(str, Enum):
26+
draft = "draft"
27+
under_review = "under_review"
28+
approved = "approved"
29+
submitted = "submitted"
30+
31+
32+
class JobStatus(str, Enum):
33+
queued = "queued"
34+
processing = "processing"
35+
completed = "completed"
36+
failed = "failed"
37+
38+
39+
class JobType(str, Enum):
40+
transcription = "transcription"
41+
extraction = "extraction"
42+
form_generation = "form_generation"
43+
batch_form_generation = "batch_form_generation"
44+
report_generation = "report_generation"
45+
46+
47+
class FormType(str, Enum):
48+
neris = "neris"
49+
nemsis_epcr = "nemsis_epcr"
50+
nibrs = "nibrs"
51+
nfirs_basic = "nfirs_basic"
52+
nfirs_fire = "nfirs_fire"
53+
nfirs_structure = "nfirs_structure"
54+
nfirs_wildland = "nfirs_wildland"
55+
nfirs_ems = "nfirs_ems"
56+
nfirs_hazmat = "nfirs_hazmat"
57+
nfirs_apparatus = "nfirs_apparatus"
58+
nfirs_personnel = "nfirs_personnel"
59+
nfirs_arson = "nfirs_arson"
60+
nfirs_casualty_civilian = "nfirs_casualty_civilian"
61+
nfirs_casualty_responder = "nfirs_casualty_responder"
62+
cal_fire_ics209 = "cal_fire_ics209"
63+
osha_301 = "osha_301"
64+
un_ssirs = "un_ssirs"
65+
state_georgia = "state_georgia"
66+
state_california = "state_california"
67+
state_new_york = "state_new_york"
68+
69+
70+
class IncidentCategory(str, Enum):
71+
fire = "fire"
72+
ems = "ems"
73+
rescue = "rescue"
74+
hazardous_conditions = "hazardous_conditions"
75+
service_call = "service_call"
76+
good_intent = "good_intent"
77+
false_alarm = "false_alarm"
78+
law_enforcement = "law_enforcement"
79+
80+
81+
class CauseCertainty(str, Enum):
82+
confirmed = "confirmed"
83+
probable = "probable"
84+
suspected = "suspected"
85+
undetermined = "undetermined"
86+
87+
88+
class InjurySeverity(str, Enum):
89+
minor = "minor"
90+
moderate = "moderate"
91+
severe = "severe"
92+
fatal = "fatal"
93+
94+
95+
class RateOfSpread(str, Enum):
96+
slow = "slow"
97+
moderate = "moderate"
98+
rapid = "rapid"
99+
extreme = "extreme"
100+
101+
102+
class PeriodType(str, Enum):
103+
monthly = "monthly"
104+
quarterly = "quarterly"
105+
annual = "annual"
106+
107+
108+
class OutputFormat(str, Enum):
109+
pdf = "pdf"
110+
json = "json"
111+
both = "both"

app/api/v1/routes/system.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,6 @@ def get_health():
161161

162162
@router.get("/schema/incident", summary="Get canonical incident JSON Schema")
163163
def get_schema_incident():
164-
# PROVISIONAL: This 501 body shape is not canonical — it will be updated
165-
# to match the standardized ErrorResponse envelope once #543 lands.
166164
return JSONResponse(
167165
status_code=501,
168166
content={
@@ -174,7 +172,6 @@ def get_schema_incident():
174172

175173
@router.get("/schema/incident/versions", summary="Get incident schema version history")
176174
def get_schema_versions():
177-
# PROVISIONAL: Same as /schema/incident — shape will align with #543.
178175
return JSONResponse(
179176
status_code=501,
180177
content={

app/core/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,8 @@
4444
for origin in os.getenv("FRONTEND_ORIGINS", _DEFAULT_ORIGINS).split(",")
4545
if origin.strip()
4646
]
47+
48+
# --- Error handling -------------------------------------------------------
49+
# Advisory Retry-After sent to clients on 503 responses. This is a client
50+
# hint, not a measured backpressure value — the app has no queue-depth signal.
51+
RETRY_AFTER_SECONDS = 30

app/core/errors/base.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,23 @@
1+
def _default_error_code(status_code: int) -> str:
2+
return {
3+
400: "BAD_REQUEST",
4+
404: "NOT_FOUND",
5+
422: "VALIDATION_ERROR",
6+
500: "INTERNAL_ERROR",
7+
502: "BAD_GATEWAY",
8+
503: "SERVICE_UNAVAILABLE",
9+
}.get(status_code, "ERROR")
10+
11+
112
class AppError(Exception):
2-
def __init__(self, message: str, status_code: int = 400):
13+
def __init__(
14+
self,
15+
message: str,
16+
status_code: int = 400,
17+
error_code: str | None = None,
18+
detail: dict | None = None,
19+
):
320
self.message = message
4-
self.status_code = status_code
21+
self.status_code = status_code
22+
self.error_code = error_code or _default_error_code(status_code)
23+
self.detail = detail

app/core/errors/handlers.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,37 @@
11
from fastapi import Request
2+
from fastapi.exceptions import RequestValidationError
23
from fastapi.responses import JSONResponse
4+
5+
from app.core.config import RETRY_AFTER_SECONDS
36
from app.core.errors.base import AppError
47

8+
59
def register_exception_handlers(app):
610
@app.exception_handler(AppError)
711
async def app_error_handler(request: Request, exc: AppError):
12+
body: dict = {"error_code": exc.error_code, "message": exc.message}
13+
if exc.detail is not None:
14+
body["detail"] = exc.detail
15+
if exc.status_code == 503:
16+
body["retry_after_seconds"] = RETRY_AFTER_SECONDS
17+
return JSONResponse(status_code=exc.status_code, content=body)
18+
19+
@app.exception_handler(RequestValidationError)
20+
async def validation_error_handler(request: Request, exc: RequestValidationError):
21+
validation_errors = []
22+
for error in exc.errors():
23+
loc = error.get("loc", ())
24+
field = ".".join(str(x) for x in loc if x != "body")
25+
validation_errors.append({
26+
"field": field or None,
27+
"issue": error.get("msg"),
28+
"value": error.get("input"),
29+
})
830
return JSONResponse(
9-
status_code=exc.status_code,
10-
content={"error": exc.message},
11-
)
31+
status_code=422,
32+
content={
33+
"error_code": "VALIDATION_ERROR",
34+
"message": "Request validation failed",
35+
"validation_errors": validation_errors,
36+
},
37+
)

tests/test_api.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,12 +210,18 @@ def test_fill_form_template_file_not_found(self, client, mock_controller):
210210
"input_text": "some text",
211211
})
212212
assert resp.status_code == 500
213-
assert "PDF template not found" in resp.json()["error"]
213+
assert resp.json()["error_code"] == "FORM_FILL_ERROR"
214+
assert "PDF template not found" in resp.json()["message"]
214215

215216
def test_fill_form_validates_body(self, client):
216-
"""Missing required fields → 422."""
217+
"""Missing required fields → 422 with contract envelope."""
217218
resp = client.post("/forms/fill", json={})
218219
assert resp.status_code == 422
220+
body = resp.json()
221+
assert body["error_code"] == "VALIDATION_ERROR"
222+
assert len(body["validation_errors"]) >= 1
223+
assert body["validation_errors"][0]["field"] is not None
224+
assert body["validation_errors"][0]["issue"] is not None
219225

220226
def test_transcribe_success(self, client, monkeypatch):
221227
"""Audio is forwarded to the whisper sidecar and its text returned."""

0 commit comments

Comments
 (0)