--- title: "1.24.3 — phonehub: a failed carrier lookup is negative-cached instead of crashing" description: "A provider error on a never-cached phone number no longer raises IntegrityError and no longer re-bills the provider on every retry. The error is cached briefly with exponential backoff and exposed as `lookup_unavailable`; `is_valid` is never written by the error path, so a Twilio outage cannot mint…" date: "2026-09-09" tags: ["release"] canonical: "https://django-mojo.sitesmojo.com/changelog/1-24-3/" --- A provider error on a never-cached phone number no longer raises IntegrityError and no longer re-bills the provider on every retry. The error is cached briefly with exponential backoff and exposed as `lookup_unavailable`; `is_valid` is never written by the error path, so a Twilio outage cannot mint a false verdict. Consumers that read `PhoneNumber.is_valid` must check `lookup_unavailable` first. ### Changed - `PhoneNumber.refresh()` on a provider error now persists a short-lived negative cache: `lookup_expires_at` is stamped on every error at `min(15 × 2^(error_count−1), 1440)` minutes, and the error is recorded in `lookup_data` (`error`, `error_at`, `error_count`; ANSI-stripped, truncated). Nothing else on the row moves — not `is_valid`, carrier fields, `last_lookup_at` or `lookup_count`. - New `PhoneNumber.lookup_unavailable` property: true when the last attempt errored AND the row holds no successful lookup younger than `LOOKUP_TTL_DAYS`. A previously-good number keeps serving its cached verdict through a short outage; a number that cannot be refreshed for longer than the TTL is treated as having no verdict. `lookup_error` exposes the message. - `PhoneNumber.lookup()` returns `None` for a number `normalize()` cannot parse instead of attempting an insert with a null `phone_number`. - `lookup_data` is never serialized (`NO_SHOW_FIELDS`); the `default` graph exposes `lookup_unavailable` in its place. Module constants `LOOKUP_TTL_DAYS = 90` and `LOOKUP_ERROR_TTL_MINUTES = 15` replace the literals. - `PhoneNumber.refresh()` accepts a keyword-only `lookup_fn` test seam; production callers pass nothing and behaviour is unchanged. ### Fixed - `refresh()` raised `IntegrityError` (`lookup_expires_at` NOT NULL) on every failed lookup of an uncached number, so the failure was never cached and the provider was billed again on each retry. - `POST /api/phonehub/number/lookup` with `force_refresh: true` and an unparseable number returned a 500; it now returns the documented `{status: false}` body. ### Upgrade notes - `is_valid` is the carrier verdict from the last SUCCESSFUL lookup. Any consumer that treats a `PhoneNumber` row as a verdict must read `lookup_unavailable` first: a row that errored and has never succeeded still carries the field default `is_valid=True` with no carrier data. Deploy such consumers before or with this release; a consumer that reads `is_valid` naively will pass numbers whose lookup failed. - A Twilio "number not found" is still reported as a provider error, not as `is_valid=False`; this release does not distinguish permanent from transient provider failures. - No migration.