Debugging Q1 - Stripe Bug Squash (Make the Failing Tests Pass)

How the round actually works

Here is the shape candidates consistently report:

  • You choose your language and IDE beforehand, then share your screen and clone a repository the interviewer gives you. It is a real, sizeable project (people report being handed well-known open-source libraries like Express or Day.js for JavaScript, Sass for Ruby), not a toy. The one below, paykit, is a payment-gateway integration toolkit in that spirit: a package, data fixtures, an HTTP client, webhook handling, a ledger, and a real test suite.
  • The repo comes with a suite of failing tests, frequently framed as a handful of GitHub-style issues, each backed by one or more red tests. Expect on the order of 10 to 15 failures to work through.
  • It runs about 45 to 60 minutes. You will not get through everything if you flail, and that is fine: steady, well-explained progress beats a frantic scramble.
  • The single hard rule: fix the code, never the tests. The tests define correct behaviour and are off-limits. Editing a test to force it green is an instant fail signal.

The method that is actually being graded

Before touching a line, say your loop out loud and then follow it for every bug. This is the core of it:

  1. Run the suite first. Never start reading files cold. The red/green map tells you exactly where to look and gives you a way to verify every fix.
  2. Take one failing test at a time. Read what it asserts, that is the spec. Reproduce the failure and read the actual values, do not guess.
  3. Localise, do not skim. Follow the failing assertion into the one function it exercises. Reach for your debugger, a breakpoint, or a quick print, rather than eyeballing the whole file.
  4. Make the smallest fix that matches the failure. A surgical change reads as "I found the bug." A sprawling rewrite reads as "I could not."
  5. Re-run, then move on. Confirm that test is green and that you broke nothing else. Narrate what you changed and why.

The repo you are handed

paykit is a small toolkit that talks to a payment gateway over HTTP, verifies webhooks, handles currency, and keeps a ledger. Amounts are in integer minor units (cents), which is how real payment APIs represent money. Click through the tree, and read the tests/ first: they are the specification, and they are the thing you must satisfy without editing. Note that tests/dummy_gateway.py spins up a real in-process HTTP server, so the client genuinely makes network calls against canned responses.

paykit: the repository (read the tests first)

Explorer

gateway.pypaykit/gateway.py
1"""HTTP client for the Acme payment gateway."""
2import json
3import time
4import uuid
5import urllib.request
6import urllib.error
7 
8from .errors import GatewayError
9from .models import Charge
10 
11RETRYABLE_STATUS = {500, 502, 503, 504, 429}
12MAX_ATTEMPTS = 3
13 
14 
15class PaymentGateway:
16 def __init__(self, base_url, api_key):
17 self.base_url = base_url.rstrip("/")
18 self.api_key = api_key
19 
20 def _do_request(self, method, path, body=None, idempotency_key=None):
21 url = f"{self.base_url}{path}"
22 data = json.dumps(body).encode() if body is not None else None
23 req = urllib.request.Request(url, data=data, method=method)
24 req.add_header("Authorization", f"Bearer {self.api_key}")
25 req.add_header("Content-Type", "application/json")
26 if idempotency_key is not None:
27 req.add_header("Idempotency-Key", idempotency_key)
28 try:
29 with urllib.request.urlopen(req, timeout=5) as resp:
30 return json.loads(resp.read().decode())
31 except urllib.error.HTTPError as e:
32 raise GatewayError(e.code, e.read().decode())
33 
34 def _request(self, method, path, body=None):
35 attempt = 0
36 while True:
37 attempt += 1
38 key = str(uuid.uuid4()) if method == "POST" else None
39 try:
40 return self._do_request(method, path, body, key)
41 except GatewayError:
42 if attempt >= MAX_ATTEMPTS:
43 raise
44 continue
45 
46 def create_charge(self, amount, currency, source):
47 body = {"amount": amount, "currency": currency, "source": source}
48 return Charge.from_dict(self._request("POST", "/v1/charges", body))
49 
50 def get_charge(self, charge_id):
51 return Charge.from_dict(self._request("GET", f"/v1/charges/{charge_id}"))
52 
53 def list_charges(self, limit=2):
54 payload = self._request("GET", f"/v1/charges?limit={limit}")
55 return [Charge.from_dict(c) for c in payload["data"]]

Step 1: Run the suite and read the map

Resist the urge to read the source top to bottom. Run the tests and let the failures point you:

$ pytest -q
F...FFF.FF..F.FF..F                                               [100%]
======================== short test summary info ========================
FAILED tests/test_gateway.py::test_list_charges_follows_pagination - assert 2 == 5
FAILED tests/test_gateway.py::test_does_not_retry_client_error - assert 3 == 1
FAILED tests/test_gateway.py::test_retry_reuses_idempotency_key - assert (2 == 2 and 2 == 1)
FAILED tests/test_ledger.py::test_balance_excludes_non_succeeded - assert 700 == 200
FAILED tests/test_ledger.py::test_can_refund_blocks_over_refund - assert True is False
FAILED tests/test_ledger.py::test_refund_rejects_over_refund - Failed: DID NOT RAISE ValueError
FAILED tests/test_money.py::test_add_rejects_currency_mismatch - Failed: DID NOT RAISE ValueError
FAILED tests/test_money.py::test_convert_across_exponents - assert Money(150000, 'jpy') == Money(1500, 'jpy')
FAILED tests/test_webhooks.py::test_dispatch_routes_to_handler - assert [] == ['evt_9']
FAILED tests/test_webhooks.py::test_verify_accepts_valid_signature - SignatureError: signature mismatch
10 failed, 9 passed in 0.41s

Now you have a map: 10 red, 9 green across four modules, and each failure tells you the file and the exact mismatch. Say it out loud: "Ten failures across the gateway client, money, webhooks, and the ledger. Nine pass, so the plumbing works. I'll go module by module, gateway first since those look like the meatiest, and fix each with the smallest change that turns its test green." That framing alone is a strong-hire signal.

We will walk them in four groups. After each group, the panel shows the whole repository with that file fixed and the changed lines highlighted, so you can read the fix in full context and click any other file without scrolling.

🌐 Group 1: The gateway HTTP client

paykit/gateway.py is where the hardest, most realistic bugs live. These are exactly the kind of distributed-systems mistakes Stripe cares about most.

Pagination: only the first page is read

test_list_charges_follows_pagination seeds five charges and expects all five back; it gets two. The gateway is cursor-paginated (has_more plus a starting_after cursor), but list_charges reads a single page.

paykit/gateway.py

# before
def list_charges(self, limit=2):
    payload = self._request("GET", f"/v1/charges?limit={limit}")
    return [Charge.from_dict(c) for c in payload["data"]]    # only the first page

# after
def list_charges(self, limit=2):
    charges, starting_after = [], None
    while True:
        path = f"/v1/charges?limit={limit}"
        if starting_after is not None:
            path += f"&starting_after={starting_after}"
        payload = self._request("GET", path)
        charges.extend(Charge.from_dict(c) for c in payload["data"])
        if not payload.get("has_more"):
            break
        starting_after = payload["data"][-1]["id"]
    return charges

Say this: "We only read page one, so any account with more than limit charges silently loses data. I'll follow the cursor until has_more is false."

Retry policy: it retries client errors

test_does_not_retry_client_error sends a 400 and expects one request; the client makes three, because the retry loop catches every GatewayError.

paykit/gateway.py

# before
except GatewayError:
    if attempt >= MAX_ATTEMPTS:
        raise
    continue                       # retries EVERYTHING, including 4xx

# after
except GatewayError as e:
    if e.status not in RETRYABLE_STATUS or attempt >= MAX_ATTEMPTS:
        raise                      # a 4xx is a client error: it will never succeed
    continue

Say this: "A 400 is a client error; retrying it three times will never help, and for a write it risks creating duplicate charges. I'll only retry the transient statuses." This is the bug that most separates senior candidates.

Idempotency: a fresh key on every retry

test_retry_reuses_idempotency_key retries once and expects the same idempotency key on both attempts; the keys differ, because the key is generated inside the retry loop.

paykit/gateway.py

# before
while True:
    attempt += 1
    key = str(uuid.uuid4()) if method == "POST" else None   # a NEW key every attempt
    try:
        return self._do_request(method, path, body, key)

# after
idempotency_key = str(uuid.uuid4()) if method == "POST" else None  # generated ONCE
attempt = 0
while True:
    attempt += 1
    try:
        return self._do_request(method, path, body, idempotency_key)

Say this: "This is the subtle one. The key is created inside the loop, so a retried POST sends a different key and the gateway treats it as a brand-new charge. The whole purpose of an idempotency key is to make a retry safe, so it has to be stable across attempts." Connecting idempotency to the retry you just fixed is a great moment.

paykit/gateway.py fixed: yellow marks the changed lines; click any file to browse

Explorer

gateway.pymodifiedpaykit/gateway.py
1"""HTTP client for the Acme payment gateway."""
2import json
3import time
4import uuid
5import urllib.request
6import urllib.error
7 
8from .errors import GatewayError
9from .models import Charge
10 
11RETRYABLE_STATUS = {500, 502, 503, 504, 429}
12MAX_ATTEMPTS = 3
13 
14 
15class PaymentGateway:
16 def __init__(self, base_url, api_key):
17 self.base_url = base_url.rstrip("/")
18 self.api_key = api_key
19 
20 def _do_request(self, method, path, body=None, idempotency_key=None):
21 url = f"{self.base_url}{path}"
22 data = json.dumps(body).encode() if body is not None else None
23 req = urllib.request.Request(url, data=data, method=method)
24 req.add_header("Authorization", f"Bearer {self.api_key}")
25 req.add_header("Content-Type", "application/json")
26 if idempotency_key is not None:
27 req.add_header("Idempotency-Key", idempotency_key)
28 try:
29 with urllib.request.urlopen(req, timeout=5) as resp:
30 return json.loads(resp.read().decode())
31 except urllib.error.HTTPError as e:
32 raise GatewayError(e.code, e.read().decode())
33 
34 def _request(self, method, path, body=None):
35 idempotency_key = str(uuid.uuid4()) if method == "POST" else None
36 attempt = 0
37 while True:
38 attempt += 1
39 try:
40 return self._do_request(method, path, body, idempotency_key)
41 except GatewayError as e:
42 if e.status not in RETRYABLE_STATUS or attempt >= MAX_ATTEMPTS:
43 raise
44 continue
45 
46 def create_charge(self, amount, currency, source):
47 body = {"amount": amount, "currency": currency, "source": source}
48 return Charge.from_dict(self._request("POST", "/v1/charges", body))
49 
50 def get_charge(self, charge_id):
51 return Charge.from_dict(self._request("GET", f"/v1/charges/{charge_id}"))
52 
53 def list_charges(self, limit=2):
54 charges = []
55 starting_after = None
56 while True:
57 path = f"/v1/charges?limit={limit}"
58 if starting_after is not None:
59 path += f"&starting_after={starting_after}"
60 payload = self._request("GET", path)
61 charges.extend(Charge.from_dict(c) for c in payload["data"])
62 if not payload.get("has_more"):
63 break
64 starting_after = payload["data"][-1]["id"]
65 return charges

💱 Group 2: Money and currency

convert ignores currency exponents

test_convert_across_exponents converts $10.00 to JPY and expects ¥1500 (1500 minor units, since yen has no decimal places); it gets 150000, because the code scales the raw minor units by the rate.

paykit/money.py

# before
rate = rates[to_currency] / rates[self.currency]
return Money(round(self.minor_units * rate), to_currency)   # scales raw minor units

# after
rate = rates[to_currency] / rates[self.currency]
from_scale = 10 ** EXPONENT[self.currency]
to_scale = 10 ** EXPONENT[to_currency]
real_amount = (self.minor_units / from_scale) * rate
return Money(round(real_amount * to_scale), to_currency)

Say this: "USD has two decimal places and JPY has zero, so multiplying raw minor units by the rate mixes the scales. I divide out the source exponent, apply the rate, then multiply by the target exponent." (USD to EUR passed because both have two decimals, which is the trap.)

__add__ silently mixes currencies

test_add_rejects_currency_mismatch expects adding USD and EUR to raise; it silently returns a USD value.

paykit/money.py

# before
def __add__(self, other):
    return Money(self.minor_units + other.minor_units, self.currency)

# after
def __add__(self, other):
    if self.currency != other.currency:
        raise ValueError(f"cannot add {self.currency} and {other.currency}")
    return Money(self.minor_units + other.minor_units, self.currency)

Say this: "Adding two different currencies should never silently succeed; it produces a meaningless number. I'll make a mismatch a hard error."

paykit/money.py fixed: yellow marks the changed lines; click any file to browse

Explorer

money.pymodifiedpaykit/money.py
1"""Money values in integer minor units, with currency conversion."""
2 
3# Minor-unit decimal places per ISO 4217 currency.
4EXPONENT = {"usd": 2, "eur": 2, "gbp": 2, "jpy": 0, "kwd": 3}
5 
6 
7class Money:
8 def __init__(self, minor_units, currency):
9 self.minor_units = int(minor_units)
10 self.currency = currency.lower()
11 
12 def __eq__(self, other):
13 return (
14 isinstance(other, Money)
15 and self.minor_units == other.minor_units
16 and self.currency == other.currency
17 )
18 
19 def __repr__(self):
20 return f"Money({self.minor_units}, {self.currency!r})"
21 
22 def __add__(self, other):
23 if self.currency != other.currency:
24 raise ValueError(f"cannot add {self.currency} and {other.currency}")
25 return Money(self.minor_units + other.minor_units, self.currency)
26 
27 def convert(self, to_currency, rates):
28 """Convert to to_currency. rates maps a currency to units per 1 USD."""
29 to_currency = to_currency.lower()
30 rate = rates[to_currency] / rates[self.currency]
31 from_scale = 10 ** EXPONENT[self.currency]
32 to_scale = 10 ** EXPONENT[to_currency]
33 real_amount = (self.minor_units / from_scale) * rate
34 return Money(round(real_amount * to_scale), to_currency)

🔐 Group 3: Webhooks

Signature verification signs the wrong string

test_verify_accepts_valid_signature sends a correctly signed webhook and expects it to verify; it is rejected. The gateway signs "<timestamp>.<payload>" (that is why the header carries the timestamp), but the code signs only the payload.

paykit/webhooks.py

# before
expected = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()

# after
signed = f"{timestamp}.{payload}"
expected = hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest()

Say this: "The signed string has to match what the sender signed, which is timestamp dot payload, the same scheme Stripe actually uses. Right now we sign the payload alone, so every legitimate webhook fails." Worth noting the parts that are already correct here: the timestamp tolerance check (replay protection) and hmac.compare_digest (constant-time compare), so do not touch them.

dispatch looks up the wrong key

test_dispatch_routes_to_handler registers a handler for "charge.succeeded" and expects it to fire; nothing happens, because dispatch reads event_type while events are keyed by type.

paykit/webhooks.py

# before
handler = handlers.get(event.get("event_type"))   # there is no "event_type" field

# after
handler = handlers.get(event["type"])

Say this: "There is no event_type field, so handlers.get always returns None and no handler ever runs. The key is type."

paykit/webhooks.py fixed: yellow marks the changed lines; click any file to browse

Explorer

webhooks.pymodifiedpaykit/webhooks.py
1"""Verify and dispatch Acme webhook events."""
2import hmac
3import hashlib
4import json
5import time
6 
7from .errors import SignatureError
8 
9TOLERANCE_SECONDS = 300
10 
11 
12def verify_signature(payload, header, secret, now=None):
13 """Verify a webhook. header looks like 't=<ts>,v1=<sig>'. Returns the event."""
14 now = now if now is not None else time.time()
15 parts = dict(p.split("=", 1) for p in header.split(","))
16 timestamp = int(parts["t"])
17 signature = parts["v1"]
18 
19 if abs(now - timestamp) > TOLERANCE_SECONDS:
20 raise SignatureError("timestamp outside tolerance")
21 
22 signed = f"{timestamp}.{payload}"
23 expected = hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest()
24 if not hmac.compare_digest(expected, signature):
25 raise SignatureError("signature mismatch")
26 
27 return json.loads(payload)
28 
29 
30def dispatch(event, handlers):
31 """Route an event to its handler keyed by event type."""
32 handler = handlers.get(event["type"])
33 if handler is not None:
34 return handler(event)
35 return None

📒 Group 4: The ledger

balance counts charges that never settled

test_balance_excludes_non_succeeded has one succeeded charge (1000, refunded 800) and one failed charge (500), and expects a balance of 200; it gets 700, because it sums every charge regardless of status.

paykit/ledger.py

# before
for charge in self.charges.values():
    total += charge.amount - charge.refunded          # counts failed/pending too

# after
for charge in self.charges.values():
    if charge.status == "succeeded":
        total += charge.amount - charge.refunded

Say this: "A failed or pending charge inflates available funds, which on a real platform means paying out money you never actually captured. Only settled charges count."

can_refund ignores prior refunds

test_can_refund_blocks_over_refund: a 1000 charge already refunded 800 should not allow another 300; it does, because the bound is the original amount.

paykit/ledger.py

# before
def can_refund(self, charge_id, amount):
    charge = self.charges[charge_id]
    return 0 < amount <= charge.amount                # bounds by the original amount

# after
def can_refund(self, charge_id, amount):
    charge = self.charges[charge_id]
    remaining = charge.amount - charge.refunded
    return 0 < amount <= remaining

Say this: "It checks against the original amount, not what is still refundable, so you can refund the same charge twice. The bound is amount - refunded."

refund mutates without checking

test_refund_rejects_over_refund expects an over-refund to raise; refund just adds to refunded with no validation, so even after fixing can_refund, this path still over-refunds.

paykit/ledger.py

# before
def refund(self, charge_id, amount):
    charge = self.charges[charge_id]
    charge.refunded += amount
    return charge.refunded

# after
def refund(self, charge_id, amount):
    if not self.can_refund(charge_id, amount):
        raise ValueError("refund exceeds remaining balance")
    charge = self.charges[charge_id]
    charge.refunded += amount
    return charge.refunded

Say this: "The guard belongs in the mutation path, otherwise the check is just decorative. refund should refuse anything can_refund rejects."

paykit/ledger.py fixed: yellow marks the changed lines; click any file to browse

Explorer

ledger.pymodifiedpaykit/ledger.py
1"""In-memory record of charges, with balance and refund rules."""
2 
3 
4class Ledger:
5 def __init__(self):
6 self.charges = {}
7 
8 def add(self, charge):
9 self.charges[charge.id] = charge
10 
11 def balance(self):
12 """Available balance: settled money minus what has been refunded."""
13 total = 0
14 for charge in self.charges.values():
15 if charge.status == "succeeded":
16 total += charge.amount - charge.refunded
17 return total
18 
19 def can_refund(self, charge_id, amount):
20 charge = self.charges[charge_id]
21 remaining = charge.amount - charge.refunded
22 return 0 < amount <= remaining
23 
24 def refund(self, charge_id, amount):
25 if not self.can_refund(charge_id, amount):
26 raise ValueError("refund exceeds remaining balance")
27 charge = self.charges[charge_id]
28 charge.refunded += amount
29 return charge.refunded

The result: green, with the tests untouched

Ten bugs across four files, every fix a few lines, zero rewrites, and the tests are byte-for-byte the same as when you started. Here is the whole repository with all four modules now carrying a "modified" dot, and the suite comes back clean:

paykit after the fix (all four modules modified, tests untouched)

Explorer

gateway.pymodifiedpaykit/gateway.py
1"""HTTP client for the Acme payment gateway."""
2import json
3import time
4import uuid
5import urllib.request
6import urllib.error
7 
8from .errors import GatewayError
9from .models import Charge
10 
11RETRYABLE_STATUS = {500, 502, 503, 504, 429}
12MAX_ATTEMPTS = 3
13 
14 
15class PaymentGateway:
16 def __init__(self, base_url, api_key):
17 self.base_url = base_url.rstrip("/")
18 self.api_key = api_key
19 
20 def _do_request(self, method, path, body=None, idempotency_key=None):
21 url = f"{self.base_url}{path}"
22 data = json.dumps(body).encode() if body is not None else None
23 req = urllib.request.Request(url, data=data, method=method)
24 req.add_header("Authorization", f"Bearer {self.api_key}")
25 req.add_header("Content-Type", "application/json")
26 if idempotency_key is not None:
27 req.add_header("Idempotency-Key", idempotency_key)
28 try:
29 with urllib.request.urlopen(req, timeout=5) as resp:
30 return json.loads(resp.read().decode())
31 except urllib.error.HTTPError as e:
32 raise GatewayError(e.code, e.read().decode())
33 
34 def _request(self, method, path, body=None):
35 idempotency_key = str(uuid.uuid4()) if method == "POST" else None
36 attempt = 0
37 while True:
38 attempt += 1
39 try:
40 return self._do_request(method, path, body, idempotency_key)
41 except GatewayError as e:
42 if e.status not in RETRYABLE_STATUS or attempt >= MAX_ATTEMPTS:
43 raise
44 continue
45 
46 def create_charge(self, amount, currency, source):
47 body = {"amount": amount, "currency": currency, "source": source}
48 return Charge.from_dict(self._request("POST", "/v1/charges", body))
49 
50 def get_charge(self, charge_id):
51 return Charge.from_dict(self._request("GET", f"/v1/charges/{charge_id}"))
52 
53 def list_charges(self, limit=2):
54 charges = []
55 starting_after = None
56 while True:
57 path = f"/v1/charges?limit={limit}"
58 if starting_after is not None:
59 path += f"&starting_after={starting_after}"
60 payload = self._request("GET", path)
61 charges.extend(Charge.from_dict(c) for c in payload["data"])
62 if not payload.get("has_more"):
63 break
64 starting_after = payload["data"][-1]["id"]
65 return charges
$ pytest -q
...................                                               [100%]
19 passed in 0.39s

That discipline, the smallest change that turns each test green and nothing more, is the whole point of a Bug Squash.

What earns a Strong Hire

Your game plan, start to finish

  1. Run the suite immediately and read the summary as a to-do list.
  2. Pick one red test, read its assertion, reproduce the failure, and read the real values.
  3. Localise to the one function, lean on a debugger or a print, and make the minimal fix.
  4. Re-run, confirm green and nothing else broke, and narrate what changed and why.
  5. Repeat, grouping related bugs (start with the gateway), and keep talking the entire time.

Practice this loop on a few unfamiliar open-source repositories in your strongest language until "run the tests first, reproduce, fix the minimum, verify" is automatic. Do that, and Stripe's most distinctive round becomes the one where you look most like the engineer they already work with. 🚀

Was this page helpful?