Health-check 100 URLs in parallel with a worker limit
Problem statement
After a deploy you must health-check 100 hosts. One at a time, a few slow or hung hosts make the whole check take minutes. Checking all 100 at once can overload the network or the service you are checking. Run the checks in parallel with at most 10 in flight, a per-request timeout of 0.2 s, and a clean, sorted report.
There is no network. A mock fake_get(url, timeout) stands in for the HTTP call; its behaviour is fixed per host number i (from host-000 to host-099):
- if
i % 23 == 0the host hangs: the call waitstimeoutseconds and raisesTimeoutError; - otherwise it takes
(i * 37 % 120)milliseconds, then returns503ifi % 17 == 0, raisesConnectionRefusedErrorfori == 42, and returns200otherwise.
Requirements:
- Use
concurrent.futures.ThreadPoolExecutorwithmax_workers=10. - A failing or crashing check must never lose the other results.
- Print failures sorted by host (completion order is random, so the output must not depend on it), then
healthy/total. - Prove the limit held: track how many checks are running at once with a lock-protected counter, and print whether the peak stayed within 10.
Examples
Example 1
Input: python solution.py
Output: FAIL host-000: TimeoutError: no response in 0.2s
FAIL host-017: HTTP 503
FAIL host-023: TimeoutError: no response in 0.2s
FAIL host-034: HTTP 503
FAIL host-042: ConnectionRefusedError: connection refused
FAIL host-046: TimeoutError: no response in 0.2s
FAIL host-051: HTTP 503
FAIL host-068: HTTP 503
FAIL host-069: TimeoutError: no response in 0.2s
FAIL host-085: HTTP 503
FAIL host-092: TimeoutError: no response in 0.2s
89/100 healthy
peak concurrency <= 10: True
finished well under the sequential time: True
Explanation: host-000 matches both rules; the hang is checked first. The parallel run finishes in about a second, against more than 5 s one at a time.
Hints
Approach
Health checks spend almost all their time waiting on the network, so threads are a good fit even with Python's GIL: a thread that is waiting does not hold it.
- Bounded pool.
ThreadPoolExecutor(max_workers=10)starts at most 10 threads. All 100 tasks are submitted up front; the pool queues them and runs 10 at a time. The limit protects both the machine running the checks and the fleet being checked. - Map futures back to URLs.
futures = {pool.submit(check, url): url ...}.as_completedyields futures in the order they finish, which is effectively random, so the dict is how you know which result belongs to which host. - Errors stay per task.
checkturns expected failures (timeouts, refused connections, bad status) into result strings. An unexpected exception insidecheckis re-raised byfut.result(), caught in the main loop and recorded ascrashed, so one bug cannot lose the other 99 results. - Deterministic output. Results go into a dict and are printed sorted by URL after the pool finishes. Printing from worker threads would interleave lines in a different order on every run.
- Proving the limit.
in_flightis shared between threads, so it is only changed under aLock, and thefinallyblock decrements it even when the call raised.peakrecords the maximum. Without the lock, two threads could read and write the counter at the same time and the number would be wrong.
With 10 workers the total time is roughly the sum of latencies divided by 10, bounded below by the slowest single check.
O(n * latency / workers)Space O(n)import threadingimport timefrom concurrent.futures import ThreadPoolExecutor, as_completed URLS = [f"http://host-{i:03d}.internal/healthz" for i in range(100)]MAX_WORKERS = 10TIMEOUT = 0.2 in_flight = 0peak = 0lock = threading.Lock() def fake_get(url, timeout): # Mock HTTP call. Behaviour is fixed per host so every run gives the same answer. i = int(url.split("-")[1][:3]) latency = 5.0 if i % 23 == 0 else (i * 37 % 120) / 1000 # 0-119 ms, or a hang if latency > timeout: time.sleep(timeout) raise TimeoutError(f"no response in {timeout}s") time.sleep(latency) if i % 17 == 0: return 503 if i == 42: raise ConnectionRefusedError("connection refused") return 200 def check(url): global in_flight, peak with lock: in_flight += 1 peak = max(peak, in_flight) try: status = fake_get(url, timeout=TIMEOUT) return ("ok" if status == 200 else f"HTTP {status}") except (TimeoutError, ConnectionError) as e: return f"{type(e).__name__}: {e}" finally: with lock: in_flight -= 1 start = time.monotonic()results = {}with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool: futures = {pool.submit(check, url): url for url in URLS} for fut in as_completed(futures): url = futures[fut] try: results[url] = fut.result() except Exception as e: # a bug in check() must not lose the other results results[url] = f"crashed: {e!r}"elapsed = time.monotonic() - start failed = {u: r for u, r in results.items() if r != "ok"}for url in sorted(failed): print(f"FAIL {url.split('//')[1].split('.')[0]}: {failed[url]}")print(f"{len(results) - len(failed)}/{len(results)} healthy")print(f"peak concurrency <= {MAX_WORKERS}: {peak <= MAX_WORKERS}")print(f"finished well under the sequential time: {elapsed < 3}")Follow-up questions
- Add an overall deadline: after 30 s, report every check that has not finished as
unknown. - Retry each failed host once before reporting it, without blocking the other workers.
- Rewrite it with
asyncioand aSemaphore(10). When would that be the better choice?
Frequently asked questions
Checking many hosts or endpoints quickly is a core SRE and deploy task: smoke tests, fleet audits, canary verification. The interview version checks that you bound concurrency, keep per-task failures isolated, and produce a stable report.
The work is waiting on I/O, not computing. Threads are cheap and release the GIL while blocked on sockets, so they parallelize I/O well. Processes help for CPU-heavy work; for network checks they only add start-up cost and data copying.
Start from what the targets and the network can tolerate, not from CPU count. Ten to a few dozen concurrent HTTP checks is typical for a script. Measure: raising the limit stops helping once you hit bandwidth, file descriptor or target-side rate limits.