AER

← Investigations

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:

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

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

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

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