Cleaning 15,000 Contacts with a Chain of Responsibility
This project began as an excuse to learn Python by solving a real problem: my Google Address Book was a mess, and the mess had a cause. Years ago I imported roughly 15,000 student records from a leadership development group I volunteer with, wanting quick access to phone numbers in an emergency. What I didn’t account for was the blast radius: apps like WhatsApp and Facebook Messenger scan your device contacts to suggest connections — and suddenly my social apps were full of strangers from a database.
Worse, a manual batch delete was a non-starter for the source on GitHub: during my time in that community I’d made genuine friendships, and those records were mixed into the same imported batch. I needed to delete most of 15,000 records while keeping the handful that were actually mine.
The criteria for “keep”
Bulk import made the records easy to add, so I went looking for signals that separated personal contacts from database entries. Three criteria emerged:
- If a record has more than one phone number, keep it.
- If a record has more than one label, keep it.
- If a record has a labeled phone number (Mobile, Work, Office), keep it.
Imported database rows are sparse — one number, one label, no annotation. Real relationships accumulate detail. With those criteria validated by hand, I wrote the code.
Chain of Responsibility for the filters
The filtering fit the Chain of Responsibility pattern perfectly: pass each contact through a series of handlers, where any handler can claim the record (“skip this one — it looks personal”) and stop the chain. An abstract handler defines set_next and pass-through logic; concrete handlers each encode one criterion:
class PhoneNumberWithLabelHandler(AbstractHandler):
def handle(self, contact):
phone_numbers = contact.get("phoneNumbers", [])
if any("contactGroupMembership" in phone for phone in phone_numbers):
return ("Skipped", "Phone number has a label")
return super().handle(contact)
The handlers chain together at composition time:
def record_filters():
multiple_phone_numbers_handler.set_next(multiple_labels_handler)
phone_number_with_label_handler.set_next(multiple_phone_numbers_handler)
return phone_number_with_label_handler
A record that survives every handler gets deleted; any handler that returns a result diverts it to the “kept” pile. Adding a new criterion later means writing one new handler and inserting it into the chain — nothing else changes.
From reactive backoff to a rate-limiting Decorator
The cleanup talks to the Google People API, which enforces a rate limit (90 calls per minute). My first encounter with that limit was a 429 error, and my first fix was reactive: retry with exponential pause — 2 seconds, then 4, then 8.
It worked, sort of. The console output was exciting, but the API metrics told the real story: a sawtooth of call spikes followed by error bursts. I was slamming the ceiling, backing off, and slamming it again.
The better fix was to be intentional about pacing, and the Decorator pattern was the right tool — wrap the API call in a function that waits only as long as needed to stay under the limit:
def rate_limited_calls_per_min(max_per_minute):
min_interval = 60.0 / float(max_per_minute)
def decorate(func):
last_time_called = [0.0]
@wraps(func)
def rate_limited_function(*args, **kwargs):
elapsed = time.perf_counter() - last_time_called[0]
left_to_wait = min_interval - elapsed
if left_to_wait > 0:
time.sleep(left_to_wait)
ret = func(*args, **kwargs)
last_time_called[0] = time.perf_counter()
return ret
return rate_limited_function
return decorate
@rate_limited_calls_per_min(90)
def delete_contact_api_call(service, contact_resource_name):
service.people().deleteContact(resourceName=contact_resource_name).execute()
The key refactor was isolating the API call into its own function so that only the network call is timed. The result flipped the metrics chart from sawtooth to a smooth, steady trend line of calls — the API quota used efficiently instead of fought against.
What it taught me
Two patterns, one lesson each. The Chain of Responsibility turned a tangle of “if” conditions into a pipeline I could reason about one criterion at a time — and the criteria themselves came from examining the data, not from guessing. The Decorator turned a reactive error handler into a proactive pacing mechanism, and the difference showed up immediately in the API dashboards.
I also learned something about myself as an engineer: the code has optimization opportunities left — the API interaction logic could be broken into more focused domain classes — and I’ve decided to leave it. Enough to get the job done is a legitimate final state for a learning project, and the reward is a clean address book after nearly a decade of ignoring the mess.
If you’re curious, the full README includes the API metric charts that motivated the backoff-to-decorator switch.