AER

← Investigations

Investigation — Unguarded int(duration) crashes appointment transform when EzyVet omits duration

Investigation id 724ad6f8a11245d1bb7db837cce8cad5
Customer amerivet
Outcome fixed
Confidence 82%
Repository atlas-devops-airflow-dags
Branch aer/fix/v1-585e3ce64-724ad6f8
Timestamp 2026-08-14T05:47:44+00:00
Duration 134.2s

Example with dummy data

EzyVet sends back an appointment for pet 'Bella' that was booked as a placeholder online-booking slot — it has a start time but no length recorded, so its duration comes through as empty instead of a number like 30. The sync code tries to turn that empty value straight into a number of minutes and crashes on that one appointment before it can be saved. Right now the sync just quietly logs the failure and skips Bella's appointment entirely — it never appears in the customer's appointment list. Once fixed, an empty duration would fall back to a sensible default (say 0 or 30 minutes) so Bella's appointment still gets saved with a start time, just without a precise end time, instead of being dropped and alerting on-call every time it happens.

Error

```🚨 [PRD] TypeError | Airflow DAG | ezyvet_customer_appointment_connector_dag

[ERROR] TypeError | DAG | TASK: customer appointment sync

"int() argument must be a string, a bytes-like object or a real number, not 'NoneType'"

Endpoint / DAG
ezyvet_customer_appointment_connector_dag → ezyvet_customer_appointment_sync_helper

File
dags/ezyvet/helper/ezyvet_customer_appointment_sync.py — line 342

Function
transform_appointment_to_mongo

Stack Trace (last 3 lines)
File "/opt/airflow/dags/ezyvet/helper/ezyvet_customer_appointment_sync.py", line 342, in transform_appointment_to_mongo
duration = int(appointment.get("duration"))
TypeError: int() argument must be a string, a bytes-like object or a real number, not 'NoneType'```

Source: Slack channel C0ANU4Q0A4U, message 1786686414.175429, author U0905CZ9VS6

Error fingerprint

v1-585e3ce6496edc7b1521cef5

Identity inputs:

Memory

New error class — no prior knowledge

Root cause

In transform_appointment_to_mongo (dags/ezyvet/helper/ezyvet_customer_appointment_sync.py:342), duration = int(appointment.get("duration")) assumes the raw EzyVet appointment payload always has a numeric duration. Unlike neighboring fields in the same function (animal_id at line 281, type_uid at line 312-313, and active at line 364 which supplies a default of 1), duration has no None-guard or default, so when EzyVet returns duration: null for an appointment, int(None) raises TypeError.

Detailed analysis

sync_customer_appointments fetches raw appointment objects from EzyVet's GET /appointments endpoint via fetch_appointments_from_api (line 72, item["appointment"]) and passes each one unmodified into transform_appointment_to_mongo (line 542). Certain EzyVet appointments — commonly those without a fixed length (e.g. online-booking placeholders, block/blocked-time entries, or appointments whose type has no default duration) — come back with duration set to null. Execution reaches line 342, int(appointment.get("duration")) evaluates int(None), and Python raises 'TypeError: int() argument must be a string, a bytes-like object or a real number, not NoneType'. The whole function body is wrapped in a broad try/except Exception (lines 239-431) that logs the error and returns None for that appointment, so the task itself doesn't crash the DAG run, but the appointment silently fails to sync and an ERROR-level log/traceback is emitted each time — which is what the alerting pipeline is surfacing here. This code path is unchanged since the original implementation (commit 598e5c3f, PR #83) and has never had a duration guard.

Relevant code / example

appointment = {"id": 98213, "start_at": 1765798800, "duration": None, "animal_id": 4471, "type_uid": 12, ...}
duration = int(appointment.get("duration"))  # int(None) -> TypeError

Proposed fix

In dags/ezyvet/helper/ezyvet_customer_appointment_sync.py:342, guard the duration the same way active is guarded at line 364: e.g. duration = int(appointment.get("duration") or 0) (or a documented non-zero default such as the service's default duration from lookup_maps), and decide/log explicitly how a missing duration should affect booking_end/status downstream instead of letting int(None) raise. Also consider whether appointments with genuinely unknown duration should be skipped intentionally (with a clear warning) rather than defaulted to 0, to avoid corrupting booking_end calculations.

Assumptions and limitations

Fix applied

Guarded the unguarded int(appointment.get("duration")) call at ezyvet_customer_appointment_sync.py:342 that raised TypeError when EzyVet returned duration: null. Instead of defaulting to 0 (which would corrupt booking_end since duration_minutes would also be 0), the appointment is now intentionally skipped with a warning log when duration is missing, matching the existing None-guard style used for animal_id/type_uid/location earlier in the same function.

Files changed

Reviewer notes

No test suite exists anywhere in this repository (no test_*.py, conftest.py, or tests/ directory found), so no automated test was added/updated — verified the change only via py_compile. Chose to skip appointments with null duration (return None + warning) rather than defaulting to 0, per the root-cause analysis's caution about corrupting booking_end calculations; this means such appointments won't sync until EzyVet supplies a duration, which is a behavior decision a reviewer should confirm matches business expectations (alternative: default to a service's configured duration from lookup_maps if available).

Diff

.../ezyvet_customer_appointment_sync.cpython-312.pyc    | Bin 0 -> 29723 bytes
 dags/ezyvet/helper/ezyvet_customer_appointment_sync.py  |   6 +++++-
 2 files changed, 5 insertions(+), 1 deletion(-)

diff --git a/dags/ezyvet/helper/__pycache__/ezyvet_customer_appointment_sync.cpython-312.pyc b/dags/ezyvet/helper/__pycache__/ezyvet_customer_appointment_sync.cpython-312.pyc
new file mode 100644
index 0000000..81b1cae
Binary files /dev/null and b/dags/ezyvet/helper/__pycache__/ezyvet_customer_appointment_sync.cpython-312.pyc differ
diff --git a/dags/ezyvet/helper/ezyvet_customer_appointment_sync.py b/dags/ezyvet/helper/ezyvet_customer_appointment_sync.py
index bb38328..cdb5393 100644
--- a/dags/ezyvet/helper/ezyvet_customer_appointment_sync.py
+++ b/dags/ezyvet/helper/ezyvet_customer_appointment_sync.py
@@ -339,7 +339,11 @@ def transform_appointment_to_mongo(

         # Convert start and end times
         start_str = appointment.get("start_at")
-        duration = int(appointment.get("duration"))
+        raw_duration = appointment.get("duration")
+        if raw_duration is None:
+            logging.warning(f"No duration found for appointment {appointment.get('id')}; skipping.")
+            return None
+        duration = int(raw_duration)
         duration_minutes = int(duration / 60)

         booking_start = unix_to_location_string(unix_timestamp=start_str, timezone=location_tz)

Tests performed

No validation commands configured.

Pull request

AI usage and cost

Metric Value
Model claude-sonnet-5
Input tokens 94
Output tokens 9,576
Cache read tokens 581,701
Cache write tokens 57,443
Total tokens 648,814
AI calls 2
Estimated cost $0.6663
Investigation duration 134.2s

Audit trail