Investigation — Unguarded None + int when ezyvet payload lacks start_at
| Investigation id | 9b9f5d0ab3c2493baf733090f0ab7651 |
| Customer | amerivet |
| Outcome | fixed |
| Confidence | 90% |
| Repository | atlas-backend-bms |
| Branch | aer/fix/v1-83670ee1f-9b9f5d0a |
| Timestamp | 2026-08-13T20:38:05+00:00 |
| Duration | 238.9s |
Example with dummy data
Picture the vet clinic's calendar system sending a message to say 'appointment #555 for resource (room/vet) 12 is confirmed, it lasts 30 minutes' — but this particular message forgot to say when it starts. The receiving system tries to work out the finish time by adding '30 minutes' to 'when it starts', but 'when it starts' is blank, so the math breaks and the whole request fails with an error page instead of saving the confirmation. Once fixed, if the start time is missing the system will treat it as unknown (skip the finish-time math or log a warning) instead of crashing, so the confirmation is still recorded and staff aren't left wondering why the booking silently failed.
Error
```🚨 [PRD] TypeError | HTTP 500 | `POST /customers/ezyvet/appointment/confirmed`
[ERROR] TypeError | HTTP | STATUS: 500
"unsupported operand type(s) for +: 'NoneType' and 'int'"
Endpoint
`POST /customers/ezyvet/appointment/confirmed`
File
`ezyvet_webhook_controller.py` — line *69*
2026-08-13T19:04:22.884103+00:00
Pod Details
`atlas-backend-bms-7d9f8c6b5-xk2pq`
Function
`ezyvet_appointment_confirmed`
Log ID: `b72e4a19-8c1d-4f55-a9e0-3d6f1c8b5e20`
Stack Trace (last 3 lines)
`File "/app/controller/ezyvet_webhook_controller.py", line 69, in ezyvet_appointment_confirmed`
` ezyvet_end_at=data.get("start_at") + data.get("duration", 0),`
`TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'````
Source: Slack channel C0BPYEVA3NH, message 1786653463.771559, author U0905CZ9VS6
Error fingerprint
v1-83670ee1f35a3e5a65333695
Identity inputs:
type=typeerrormsg=unsupported operand type s for str and strservice=op=/customers/ezyvet/appointment/confirmedframes=controller/ezyvetwebhookcontroller.py:ezyvetappointmentconfirmed
Memory
New error class — no prior knowledge
Root cause
In controller/ezyvet_webhook_controller.py:69, ezyvet_end_at=data.get("start_at") + data.get("duration", 0) assumes data.get("start_at") is always a number. It has no default and no None-check, so when ezyvet's appointment object omits/nulls start_at, None + int raises TypeError. The same unguarded pattern is duplicated at lines 113 and 138 in the same file and at services/customer_service.py:1209.
Detailed analysis
An ezyvet 'appointment confirmed' webhook POST arrives with a JSON body containing an items list of appointment events. The handler picks the item with the highest appointment.id (line 44-46), then immediately tries to schedule a background task, computing ezyvet_end_at by adding start_at and duration (line 69) before the background task is even queued. Because data.get("start_at") returns None for this particular appointment payload (the field was missing or explicitly null in the webhook body ezyvet sent), Python raises TypeError: unsupported operand type(s) for +: 'NoneType' and 'int' synchronously inside the request handler, which FastAPI surfaces as an HTTP 500. Git history (PR #648, 82246ea5) shows this arithmetic was introduced in March 2026 to pass computed end times downstream to update_status_via_ezyvet, and no later commit (including the block-appointment fix #933 and its revert #941, which addressed a different field — resources/resource ids) ever added a null check for start_at.
Relevant code / example
data = {"uid": "abc123", "id": 555, "resources": [{"id": 12}], "duration": 30} # note: no "start_at" key
ezyvet_end_at = data.get("start_at") + data.get("duration", 0) # None + 30 -> TypeError
Proposed fix
In controller/ezyvet_webhook_controller.py, replace the three occurrences of data.get("start_at") + data.get("duration", 0) (lines 69, 113, 138) with a guarded computation, e.g. start_at = data.get("start_at"); ezyvet_end_at = start_at + data.get("duration", 0) if start_at is not None else None, and log a warning when start_at is missing so the missing-data case is visible instead of silently producing end_at=None. Apply the same guard to services/customer_service.py:1209. Also consider validating required ezyvet appointment fields (uid, start_at, resources) up front and returning a 200 'skipped'/'bad payload' response rather than letting downstream code assume they're always present, consistent with the existing empty-items guard at line 40-42.
Assumptions and limitations
- The exact reason ezyvet omitted/nulled start_at for this specific payload (e.g., an unscheduled/waitlisted appointment, a partial webhook retry, or an ezyvet-side data issue) could not be confirmed without the actual raw payload; only that the field can legitimately be missing/null and the code doesn't handle it.
- start_at is expected to be a unix timestamp (int), inferred from its use with unix_to_location_string elsewhere in services/customer_service.py.
- ⚠️ Did not have access to the actual failing request payload/log body, only the stack trace and line number.
- ⚠️ Did not confirm from ezyvet's API documentation whether start_at is officially nullable for certain appointment states; inferred purely from code behavior and repo history.
Fix applied
Guarded the three unguarded data.get("start_at") + data.get("duration", 0) arithmetic sites in controller/ezyvet_webhook_controller.py (confirmed handler line 69, delete handler lines 113 and 138) and the equivalent site in services/customer_service.py:1209, so a missing/null start_at in an ezyvet webhook payload no longer raises TypeError. Each site now computes end_at as None when start_at is None instead of letting None + int blow up, and the controller logs a warning when this happens so the missing-data case is visible.
Files changed
controller/ezyvet_webhook_controller.pyservices/customer_service.py
Reviewer notes
No test suite exists in this repository (no tests/ directory, no pytest config found), so per the root-cause guidance I did not add a new test file — verified the change only via py_compile syntax check. In the delete handler I also deduplicated the repeated data.get("start_at")/end_at computation by reusing the already-computed start_at/end_at locals in the background_tasks.add_task call, which was already effectively the same value being recomputed. Downstream code (update_status_via_ezyvet) can now receive ezyvet_end_at=None; it was already treating these as opaque values passed to unix_to_location_string/other formatting logic only for its own locally-fetched data, not these params, so no further changes were needed there for this fix. Consider the suggested longer-term validation (require uid/start_at/resources up front, return skipped/200) as follow-up work, not done here to keep blast radius minimal.
Diff
controller/ezyvet_webhook_controller.py | 20 ++++++++++++++------
services/customer_service.py | 6 +++++-
2 files changed, 19 insertions(+), 7 deletions(-)
diff --git a/controller/ezyvet_webhook_controller.py b/controller/ezyvet_webhook_controller.py
index 52a9eb0e..231455f0 100644
--- a/controller/ezyvet_webhook_controller.py
+++ b/controller/ezyvet_webhook_controller.py
@@ -58,6 +58,11 @@ async def ezyvet_appointment_confirmed(
f"Appointment id: {appointment_id}, Resource id: {resource_id} , External ezyvet appointment id: {external_ezyvet_appointment_id}"
)
+ start_at = data.get("start_at")
+ if start_at is None:
+ logging.warning(f"Appointment id: {appointment_id} received with no start_at in payload")
+ ezyvet_end_at = start_at + data.get("duration", 0) if start_at is not None else None
+
background_tasks.add_task(
update_status_via_ezyvet,
appointment_id,
@@ -65,8 +70,8 @@ async def ezyvet_appointment_confirmed(
"confirmed",
resource_id,
resource_id,
- ezyvet_start_at=data.get("start_at"),
- ezyvet_end_at=data.get("start_at") + data.get("duration", 0),
+ ezyvet_start_at=start_at,
+ ezyvet_end_at=ezyvet_end_at,
)
return {"status": "success"}
@@ -110,14 +115,17 @@ async def ezyvet_appointment_delete(
)
status = "confirmed"
- end_at = data.get("start_at") + data.get("duration", 0)
+ start_at = data.get("start_at")
+ if start_at is None:
+ logging.warning(f"Appointment id: {appointment_id} received with no start_at in payload")
+ end_at = start_at + data.get("duration", 0) if start_at is not None else None
appointment_active_status = data.get("active", True)
logging.info(f"appointment_active_status:{appointment_active_status}")
if (
last_appointment
and last_appointment.get("payload", {}).get("resource_id") == resource_id
and last_appointment.get("payload", {}).get("status") == "confirmed"
- and last_appointment.get("payload", {}).get("ezyvet_start_at") == data.get("start_at")
+ and last_appointment.get("payload", {}).get("ezyvet_start_at") == start_at
and last_appointment.get("payload", {}).get("ezyvet_end_at") == end_at
and not appointment_active_status
):
@@ -134,8 +142,8 @@ async def ezyvet_appointment_delete(
status,
old_resource_id,
resource_id,
- ezyvet_start_at=data.get("start_at"),
- ezyvet_end_at=data.get("start_at") + data.get("duration", 0),
+ ezyvet_start_at=start_at,
+ ezyvet_end_at=end_at,
cancellation_reason_text=data.get("cancellation_reason_text"),
)
diff --git a/services/customer_service.py b/services/customer_service.py
index c7321631..cb76aaaa 100644
--- a/services/customer_service.py
+++ b/services/customer_service.py
@@ -1206,7 +1206,11 @@ async def update_status_via_ezyvet(
"end_time": ezyvet_end_time,
"shift": shift,
"ezyvet_start_at": data.get("start_at"),
- "ezyvet_end_at": data.get("start_at") + data.get("duration", 0),
+ "ezyvet_end_at": (
+ data.get("start_at") + data.get("duration", 0)
+ if data.get("start_at") is not None
+ else None
+ ),
"resource_id": new_resource_id,
},
"webhook_type": "appointment",
Tests performed
❌ lint: flake8 . (exit 127)
❌ format: black --check . && isort --check-only . (exit 127)
Output — lint
/bin/sh: 1: flake8: not found
Output — format
/bin/sh: 1: black: not found
Pull request
- https://github.com/shyftlabs/atlas-backend-bms/pull/957
- Branch
aer/fix/v1-83670ee1f-9b9f5d0a
AI usage and cost
| Metric | Value |
|---|---|
| Model | claude-sonnet-5 |
| Input tokens | 64 |
| Output tokens | 12,784 |
| Cache read tokens | 1,093,736 |
| Cache write tokens | 63,210 |
| Total tokens | 1,169,794 |
| AI calls | 2 |
| Estimated cost | $0.9025 |
| Investigation duration | 238.9s |
Audit trail
- 2026-08-13T20:38:05+00:00 fingerprint v1-83670ee1f35a3e5a65333695
- 2026-08-13T20:38:05+00:00 memory recall → new
- 2026-08-13T20:38:11+00:00 matched repository atlas-backend-bms
- 2026-08-13T20:38:47+00:00 worktree /data/repositories/amerivet/atlas-backend-bms/worktrees/error-v1-83670ee1f-9b9f5d0a on aer/fix/v1-83670ee1f-9b9f5d0a
- 2026-08-13T20:40:46+00:00 analysis complete, confidence 85%
- 2026-08-13T20:42:00+00:00 fix applied to 2 file(s)
- 2026-08-13T20:42:00+00:00 validation passed
- 2026-08-13T20:42:00+00:00 changes committed
- 2026-08-13T20:42:04+00:00 PR opened https://github.com/shyftlabs/atlas-backend-bms/pull/957