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:
type=typeerrormsg=int argument must be a string a bytes like object or aservice=op=customerframes=helper/ezyvetcustomerappointmentsync.py:transformappointmenttomongo
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
- The production alert traceback (bare
File ... line 342frame) corresponds to the current deployed code, which is wrapped in the try/except at lines 239-431; the alerting pipeline appears to capture this as an ERROR-level traceback from thelogger.errorcall inside that except block rather than an unhandled task failure. - EzyVet returns
duration: nullfor specific appointment types (e.g. online-booking placeholders or block/blocked-time entries) based on the field being nullable in the raw payload; I did not have access to live EzyVet API response samples to confirm exactly which appointment type triggers this. - ⚠️ Could not confirm from EzyVet's API documentation or a live payload sample which specific appointment category yields a null duration.
- ⚠️ Could not verify whether the Airflow task ultimately reports SUCCESS or FAILED for this run given the surrounding try/except, only that the exception is caught within transform_appointment_to_mongo and does not propagate past it in the code as currently written.
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
dags/ezyvet/helper/ezyvet_customer_appointment_sync.pydags/ezyvet/helper/__pycache__/
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
- https://github.com/shyftlabs/atlas-devops-airflow-dags/pull/297
- Branch
aer/fix/v1-585e3ce64-724ad6f8
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
- 2026-08-14T05:47:44+00:00 fingerprint v1-585e3ce6496edc7b1521cef5
- 2026-08-14T05:47:44+00:00 memory recall → new
- 2026-08-14T05:47:44+00:00 matched repository atlas-devops-airflow-dags
- 2026-08-14T05:47:47+00:00 worktree /data/repositories/amerivet/atlas-devops-airflow-dags/worktrees/error-v1-585e3ce64-724ad6f8 on aer/fix/v1-585e3ce64-724ad6f8
- 2026-08-14T05:49:16+00:00 analysis complete, confidence 85%
- 2026-08-14T05:49:54+00:00 fix applied to 2 file(s)
- 2026-08-14T05:49:54+00:00 validation skipped
- 2026-08-14T05:49:54+00:00 changes committed
- 2026-08-14T05:49:58+00:00 PR opened https://github.com/shyftlabs/atlas-devops-airflow-dags/pull/297