backend: clean up Twilio caller IDs when an account is deleted#7642
Conversation
Iterate a user's phone_numbers subcollection and call Twilio's
outgoing_caller_ids({sid}).delete() for each entry that carries a
twilio_sid. Best-effort: per-entry exceptions and the phone-number list
fetch are caught and logged so the caller (delete_account background
wipe) can keep going to the Firestore wipe even if Twilio is down.
Returns the count of successfully deleted caller IDs for visibility.
The lazy import of database.phone_calls keeps the test module
stub-friendly.
Refs #7640
Call delete_user_caller_ids(uid) inside _background_wipe_user_data before delete_user_data(uid) so the twilio_sid metadata is still available when the Twilio cleanup runs. Without this, deleting an account left every verified caller ID registered on our Twilio account forever. When another user (or the same user signing back up with a fresh uid) later tried to verify the same number, Twilio rejected the request with error 21450 and the backend returned "This phone number is already registered." The only remediation was an engineer manually calling outgoing_caller_ids.delete on the orphaned sid. Closes #7640
Five cases:
- calls delete_caller_id once per phone_numbers doc with a twilio_sid
- skips entries with missing / None / empty twilio_sid
- keeps going when one twilio call raises (rest still attempted)
- returns 0 cleanly when the user has no phone_numbers
- swallows a Firestore error fetching phone_numbers so the caller
can still proceed with the Firestore wipe
The test installs a stub for database.phone_calls before importing
utils.twilio_service so the unit suite does not require firebase_admin
or the rest of the database init chain.
Greptile SummaryThis PR fixes a resource leak where deleted Omi accounts left their verified Twilio outgoing caller IDs registered at the account level, permanently blocking re-verification of those phone numbers for any future user. The fix hooks into the existing
Confidence Score: 5/5Safe to merge — the Twilio cleanup is fully best-effort and cannot block or corrupt the Firestore wipe that follows it. The ordering guarantee (Twilio before Firestore) is correctly enforced by sequential calls in the background task. delete_user_caller_ids cannot raise to its caller under any real condition — both the Firestore list and each per-entry Twilio call are independently caught and logged. No data loss or half-deleted state is possible. No files require special attention. Important Files Changed
Sequence DiagramsequenceDiagram
participant Client
participant Router as routers/users.py
participant TwilioSvc as utils/twilio_service.py
participant Twilio as Twilio API
participant DB as database/phone_calls.py
participant Firestore
Client->>Router: DELETE /v1/users/delete-account
Router->>Router: _background_wipe_user_data(uid) [background thread]
Router->>TwilioSvc: delete_user_caller_ids(uid)
TwilioSvc->>DB: get_phone_numbers(uid)
DB->>Firestore: "users/{uid}/phone_numbers.stream()"
Firestore-->>DB: phone number docs
DB-->>TwilioSvc: "[{twilio_sid, ...}, ...]"
loop For each entry with twilio_sid
TwilioSvc->>TwilioSvc: delete_caller_id(sid)
TwilioSvc->>Twilio: outgoing_caller_ids(sid).delete()
Twilio-->>TwilioSvc: success / error (swallowed)
end
TwilioSvc-->>Router: deleted count
Router->>Router: delete_user_data(uid)
Note over Router,Firestore: Firestore wipe runs after Twilio cleanup
Reviews (2): Last reviewed commit: "backend: greptile review — top-level imp..." | Re-trigger Greptile |
| # error) is logged and swallowed so account-deletion callers can run this | ||
| # before a Firestore wipe without risking a half-deleted user if Twilio is | ||
| # momentarily down. | ||
| from database import phone_calls as phone_calls_db |
There was a problem hiding this comment.
In-function import violates team rule
from database import phone_calls as phone_calls_db is placed inside the function body. The team's import rule explicitly prohibits in-function imports and requires all imports to be at the module top-level. Thirteen other utils/ modules already import from database at the top level without issues (e.g. utils/stripe.py, utils/analytics.py).
The reason this was done here is to let the test stub database.phone_calls via sys.modules before twilio_service is imported — but that same stub approach works equally well with a top-level import, since the stub is installed before twilio_service is first touched. Moving the import to the top of the file would conform to the team standard without breaking the test.
Context Used: Backend Python import rules - no in-function impor... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| continue | ||
| try: | ||
| if delete_caller_id(sid): | ||
| deleted += 1 | ||
| else: | ||
| logger.warning(f'delete_user_caller_ids: twilio reported failure for sid={sid}') | ||
| except Exception as e: | ||
| logger.error(f'delete_user_caller_ids: twilio raised for sid={sid}: {e}') |
There was a problem hiding this comment.
Outer
except around delete_caller_id only guards _get_client() failures
delete_caller_id already swallows every exception inside its own try/except and returns False — the only way an exception can escape it and reach the except Exception as e block here is if _get_client() raises (e.g. missing env vars) before the inner try. Twilio API errors in client.outgoing_caller_ids(sid).delete() are absorbed by delete_caller_id and produce a False return, which flows through the logger.warning branch instead.
The code is functionally correct, but the test comment "An exception escaping delete_caller_id" and the PR description "per-entry Twilio errors are caught + logged" both imply Twilio 5xx errors will hit the except path — they won't. Consider adding a brief inline note clarifying that this guard is specifically for _get_client() raising, so future maintainers don't conflate the two paths.
Two P2 comments on #7642: - P2: in-function `from database import phone_calls` violated the backend module rule that all imports live at the top level. Hoisted to the top of utils/twilio_service.py; the test's sys.modules stub still works because it is installed before twilio_service is first imported. - P2: the outer `try/except` around `delete_caller_id(sid)` cannot ever see a Twilio API error — those are absorbed by delete_caller_id's own internal handler and surface as a False return on the warning path. The outer guard is reachable only if `_get_client()` raises (missing TWILIO_* env vars, credential rotation, etc.). Clarified the docstring, the inline comment, and the test-case comment so future maintainers don't conflate the two paths. Renamed the simulated error string in the test to match the actual failure mode.
|
@greptile-apps re-review |
Closes #7640
Problem
When a user deletes their omi account, the background wipe removes their Firestore data but never tells Twilio about it. The verified outgoing caller IDs they had registered stay on our Twilio account forever.
Twilio enforces uniqueness at the account level, so any later attempt to verify the same number — by a brand-new omi user, or by the same person signing back up — fails with error 21450 → "This phone number is already registered." Remediation today is an engineer manually calling
client.outgoing_caller_ids({sid}).delete()after confirming no live omi user owns the number.Fix
utils.twilio_service.delete_user_caller_ids(uid)readsusers/{uid}/phone_numbers, callsdelete_caller_id(twilio_sid)for every entry that has one. Best-effort: per-entry Twilio errors and the phone-list fetch are caught + logged so a momentary Twilio issue cannot leave the user half-deleted._background_wipe_user_data(inrouters/users.py) calls it beforedelete_user_data(uid)— must run while thetwilio_sidmetadata is still in Firestore.twilio_sid, continuing past a raising delete call, no-phone-numbers no-op, and a Firestore list failure being swallowed.Test plan
pytest tests/unit/test_twilio_account_deletion.py -v→ 5 passedpytest tests/unit/test_twilio_service.py -v(existing) → still passes