Career OS

Request Lifecycle — One Click, End to End

On the Start Here page we learned a single habit that will serve you for the rest of your career: follow the request. This page is where that habit earns its keep. We’re going to take one real click in Assure PAT — a tester selecting a profile in the Card Request wizard — and chase it all the way down to a row in MySQL and back. And then something better happens: the request fails. Quietly. With a cheerful 200 OK. We’ll use that real bug as a teaching microscope, because nothing teaches you a system faster than watching it lie to you.

⏱ ~22 min · 🟡 Intermediate · Prerequisites: Layered Architecture, APIs & Auth


1. Recap: the “follow the request” method

Here’s the four-step habit from Start Here, no changes:

The “follow the request” method

  1. Find where a request enters (a route / URL).
  2. Follow it inward: route → controller → service/model → database.
  3. Watch it come back out: database → model → controller → response.
  4. Draw the boxes you passed through. You now have the architecture.

So far we’ve only described this. Now we apply it to a concrete, real flow you can open in the codebase right now. The scenario:

A tester is in the Card Request wizard on request 105 (a draft). On the “applicable profiles” step they click profile 82 — “Offline Only Terminal” (a Discover profile) to add it to the request. They expect a green confirmation. Let’s follow that click.

Keep one number in your pocket as we go: profile 82’s issuer_id is NULL. It’s a perfectly legitimate “multi-brand” profile — it just hasn’t been pinned to one specific issuer yet. Remember that. It’s the whole story.


2. The full downward path

Let’s name the boxes first, then trace the journey through them. Six participants are involved:

flowchart LR
    U([Tester]) --> R["React panel<br/>ApplicableProfilesPanel.jsx"]
    R --> S["apiService (axios)"]
    S -->|HTTPS| M["API middleware chain"]
    M --> C["CardRequestController"]
    C --> Mod["CardRequestSelectedProfiles model"]
    Mod -->|Knex| DB[("MySQL (assurepat)")]

Read it aloud: “The tester clicks in the React panel; the panel calls the axios service; that makes an HTTPS request through the API middleware; the controller delegates to the model; the model talks to MySQL through Knex.” That’s the spine. Now the full sequence, with real data, top to bottom and back up.

Before we trace our specific click, step the canonical version one hop at a time — every web request, in any system, makes this same trip from the browser down to the database and back. Press play, watch each hop light up, and ask “what does this box actually do?” Assure PAT’s journey is just this skeleton with real names hung on it (the React panel sits in the Browser, the middleware + controller + model are the App Server, Knex talks to the Database).

That generic skeleton — Browser → DNS → Load Balancer → App Server → Cache → Database and all the way back — is what every “follow the request” walk is really tracing. Now let’s hang Assure PAT’s real names on it, hop by hop.

sequenceDiagram
    participant U as Tester
    participant R as React Panel
    participant S as apiService (axios)
    participant M as Middleware chain
    participant C as Controller
    participant Mod as Model (replaceSet)
    participant DB as MySQL

    U->>R: clicks profile 82
    Note over R: builds pairs<br/>[{profile_id:82, issuer_id:null, source:"manual"}]
    R->>S: setSelectedProfiles(105, pairs)
    S->>M: PUT /api/v2/card-requests/105/selected-profiles
    Note over M: verifyToken (JWT)<br/>extractTenant (org 260)<br/>validateRequest (zod)<br/>checkRoleAccess (RBAC)
    M->>C: setSelectedProfilesForRequest
    C->>C: is request 105 locked?
    C->>Mod: replaceSet(105, pairs)
    Mod->>Mod: diff: added / reactivated / removed
    Mod->>DB: INSERT / UPDATE rows
    DB-->>Mod: ok
    Mod-->>C: {added, reactivated, removed}
    C-->>S: 200 { message, added, reactivated, removed }
    S-->>R: response
    R->>S: GET selected-profiles (re-fetch)
    S-->>R: current list
    R-->>U: re-render panel

Now let’s walk each hop slowly — this is the part worth understanding.

2.1 The click → building “pairs” (React)

In ApplicableProfilesPanel.jsx, clicking a profile doesn’t immediately hit the server. The component first assembles a small data structure the backend understands: a list of pairs, where each pair is a { profile_id, issuer_id, source } object.

For our click it builds exactly one pair:

const pairs = [
  { profile_id: 82, issuer_id: null, source: "manual" }
];

Two things to notice. First, issuer_id is null — the panel sends what it knows, and for profile 82 it knows the issuer hasn’t been chosen yet. Second, source: "manual" — this records how the profile got added (the tester typed/picked it manually, versus it appearing in a pre-computed “applicable list”). Hold onto that source field. It will matter enormously in section 3.

Why a “pair”, not just an id?

A request can carry the same profile under different issuers (that’s the whole point of a multi-brand profile). So the unit of selection isn’t “a profile” — it’s “a profile for an issuer.” The pair captures both. This is a small data-modelling decision with big downstream consequences, which is exactly the kind of thing this page is about.

2.2 The service layer call (axios)

The panel never builds URLs or sets headers itself. It calls a function on apiService (the shared axios instance). The service layer is the one place that knows the base URL, attaches the JWT (bhtoken) from storage, and turns “I want to save these profiles” into a real HTTP request. This is the boundary between “React thinking in components” and “the network thinking in requests.”

2.3 The HTTP request

This is what actually leaves the browser — and it’s the single most important artifact in the whole story:

PUT /api/v2/card-requests/105/selected-profiles
Content-Type: application/json
Authorization: Bearer <bhtoken>

{ "pairs": [ { "profile_id": 82, "issuer_id": null, "source": "manual" } ] }

PUT because we’re replacing the whole set of selected profiles for request 105, not appending one. “Set this request’s selection to exactly these pairs.” That semantic — replace, don’t append — is why the model is called replaceSet. Keep it in mind; it explains the response shape later.

2.4 The middleware chain (the bouncer line)

Before the controller ever runs, the request walks a line of bouncers. Each one can reject it; only if all pass does the request reach business logic. In order:

#MiddlewareQuestion it answersIf it fails
1verifyTokenIs the JWT valid and unexpired?401 Unauthorized
2extractTenantWhich org is this? (sets org_id = 260)403 / 400
3validateRequestDoes the body match the schema (zod)?400 Bad Request
4checkRoleAccessIs this role allowed to do this?403 Forbidden
flowchart LR
    In([PUT request]) --> A{"verifyToken<br/>valid JWT?"}
    A -->|no| E1[401]
    A -->|yes| B{"extractTenant<br/>org_id?"}
    B -->|no| E2[400]
    B -->|yes| Cc{"validateRequest<br/>body valid?"}
    Cc -->|no| E3[400]
    Cc -->|yes| D{"checkRoleAccess<br/>allowed?"}
    D -->|no| E4[403]
    D -->|yes| Ctrl[Controller runs]

Foreshadowing

Look closely at step 3, validateRequest. Its job is to check the body is well-formed: pairs is an array, each has a profile_id, etc. Is issuer_id: null well-formed? Yes — the schema allows it, because NULL is a legal value for a multi-brand profile. So the bouncer waves it through. This is correct. Remember it.

2.5 The controller: setSelectedProfilesForRequest

Now we’re in controllers/CardRequestController.js. The controller is the coordinator — it doesn’t do the heavy lifting, it decides whether the work should happen and who should do it. For our request it does two things:

  1. Guard: is request 105 locked? A request that’s already submitted/approved can’t have its profiles changed. 105 is a draft, so the guard passes.
  2. Delegate: hand the pairs to the model — CardRequestSelectedProfiles.replaceSet(105, pairs).

That’s it. Thin controller, fat model. (We covered why in Layered Architecture: keep coordination separate from logic so each is easy to test and reuse.)

2.6 The model: replaceSet (the brain)

models/CardRequestSelectedProfiles.js is where the real work lives. replaceSet is diff-based. It doesn’t blindly delete everything and re-insert — that would churn the table and lose history. Instead it compares “what’s already saved” against “what you just sent” and computes three buckets:

  • added — pairs in your request that weren’t saved before → INSERT
  • reactivated — pairs that existed but were soft-deleted, now wanted again → UPDATE is_active = 1
  • removed — pairs that were saved but aren’t in your request → UPDATE is_active = 0

It writes those changes through Knex (the query builder), and returns the three buckets so the caller knows exactly what changed.

2.7 MySQL executes

Knex turns the buckets into SQL — INSERT INTO card_request_selected_profiles ... and/or UPDATE ... SET is_active = ?. MySQL runs them and reports success.

2.8 The response travels back up

The buckets bubble back: model → controller → axios → React. The controller wraps them in a 200:

{ "message": "Selection updated", "added": [...], "reactivated": [...], "removed": [...] }

The panel then re-fetches with a GET .../selected-profiles (it trusts the server as the source of truth, not its own optimistic guess) and re-renders the list. The tester sees the result.

You’ve now seen the full round trip twice — the generic skeleton in the visualizer and the real one in Assure PAT. Before we break it, prove to yourself you know what each hop is for.

…or, in our case, sees nothing. Let’s find out why.


3. The bug — a 200 OK that did nothing

Here is exactly what we observed. The tester clicked profile 82. The Network tab showed a clean PUT, status 200 OK. No red. No error in the console. And yet the panel re-rendered showing “nothing selected.” The response body was the tell:

{ "message": "Selection updated", "added": [], "reactivated": [], "removed": [] }

Three empty buckets. The server cheerfully reported success at having done absolutely nothing. This is a silent failure — the worst kind, because every signal says “fine.”

3.1 Why did each layer behave the way it did?

Let’s re-walk the layers, but this time asking “why didn’t you catch it?”

  • React panel — Did its job perfectly. Built {profile_id:82, issuer_id:null, source:"manual"} and sent it. NULL issuer is genuinely correct for a multi-brand profile, so there was nothing to flag.
  • Middleware (validateRequest) — Did its job perfectly. The body is well-formed; issuer_id: null is a legal value. Schema validation is about shape, not business meaning. 200-worthy.
  • Controller — Did its job perfectly. 105 isn’t locked, so it correctly delegated. It returned whatever the model gave back. The model said “I changed nothing,” so the controller faithfully reported “I changed nothing” — with a 200, because no error occurred.
  • Model (replaceSet)Here. This is where it happened. Inside the loop over pairs was this line:
if (!Number.isFinite(parseInt(issuer_id))) continue;

parseInt(null) is NaN; Number.isFinite(NaN) is false; so !false is truecontinue. The pair was silently skipped. Not rejected, not errored — skipped. The loop moved on, found no other pairs, and computed added: []. Everything downstream was then “correct” given an empty work list.

Why that line existed at all

It wasn’t malicious — it was defensive. The table card_request_selected_profiles has issuer_id as NOT NULL. You can’t insert a row without an issuer. So a past developer guarded against inserting NULL by skipping such pairs. The guard was protecting the database… by quietly throwing away the user’s click. The guard wasn’t wrong about the table; it was wrong about what to do when the issuer is missing.

3.2 The missing branch: issuer resolution

Here’s the real root cause, and it’s a beautiful one. There are two ways a profile gets added to a request:

  1. From the “applicable list” — the system pre-computes which profiles apply, and along that path it resolves the issuer before saving (looks at the profile’s brand → finds matching BINs → finds the issuer; if exactly one, auto-fills it; if many, shows a picker).
  2. Manual add (source: "manual") — the path our click took. This path sent issuer_id: null straight through and never ran the resolution step.

So the same profile (82) would save fine via path 1 and silently vanish via path 2 — because only path 1 turned the NULL into a real issuer. The manual path was missing the resolution branch entirely. Here’s the decision logic, with the missing branch highlighted:

flowchart TD
    Start([Pair: profile 82, issuer_id NULL]) --> Q{issuer_id present?}
    Q -->|yes| Save["INSERT row<br/>(issuer_id NOT NULL ok)"]
    Q -->|no| Path{Which path?}
    Path -->|applicable list| Resolve["Resolve from brand:<br/>brand to BINs to issuers"]
    Path -->|manual add| Missing["MISSING BRANCH:<br/>skip the pair (continue)"]
    Resolve --> One{Exactly one issuer?}
    One -->|yes| Auto[Auto-fill issuer] --> Save
    One -->|no| Pick[Show issuer picker] --> Save
    Missing --> Empty["added: empty -> silent fail"]
    Save --> Done([Saved])

    style Missing fill:#ffe0e0,stroke:#cc0000,stroke-width:2px
    style Empty fill:#ffe0e0,stroke:#cc0000,stroke-width:2px

The red boxes are the bug. The fix is to make the manual path do what the applicable-list path already did: when issuer_id is NULL, resolve it from the profile’s brand (Discover, brand 9 → its BINs → issuer Discover Test Issuer, id 6) before deciding to skip. If resolution yields exactly one issuer, auto-fill it; if several (e.g. a profile spanning Discover + DCI), surface a picker. Never silently continue.

3.3 How tracing localised it in minutes

Notice how fast the layer-by-layer walk pinned the culprit. We didn’t read the whole codebase. We asked, at each box, “given what arrived, did you behave correctly?” — and four boxes said an honest “yes.” Only one box, the model, did something surprising (skip on NULL). The bug is almost always at the one layer whose behaviour surprises you given its input. Tracing turns “the feature is broken” (useless) into “the model skips NULL-issuer pairs on the manual path” (a one-line fix). That compression is the entire value of this skill.


4. The lessons (keep these forever)

A 200 OK is not the same as “it worked”

HTTP status describes whether the request was processed without error — not whether it did what you wanted. A handler that decides to do nothing, and does nothing successfully, returns 200. Silent failures are the worst failures precisely because every indicator is green. Always check the body, not just the status.

Read the diff response — it tells you what really happened

replaceSet returning {added, reactivated, removed} is a gift. Three empty arrays scream “I changed nothing” louder than any status code ever could. When you design an endpoint that mutates data, return what changed, not just “ok.” When you debug one, read what changed before anything else. The empty added: [] was the single clue that cracked this.

Data shape matters as much as code

The exact same replaceSet code worked flawlessly for thousands of profiles and failed only for the ones with a NULL issuer. The code was a constant; the data was the variable. When a bug appears for some inputs and not others, stop staring at the logic and start asking “what’s different about the data that breaks it?” Here: one NULL field.

Trace layer-by-layer to localise fast

Don’t guess. Walk the request through every box and ask each one “did you behave correctly given your input?” Honest yes-es clear a layer; the one surprising answer is your bug. This turns a vague “it’s broken” into a precise, one-line fix — usually in minutes, not hours.


5. Do this yourself — watch a request in the Network tab

You don’t need any tool you don’t already have. The exact evidence that cracked this bug is sitting one keystroke away in your browser. Try it on any click in Assure PAT:

Watch a real request — 6 steps

  1. Open the dashboard. Press F12 → click the Network tab.
  2. Tick Preserve log (so navigations don’t wipe it) and filter to Fetch/XHR.
  3. Do the action — e.g. select a profile in the Card Request wizard.
  4. Click the new request in the list. Read these five fields, in order:
    • Request URLwhere did it go? (/api/v2/card-requests/105/selected-profiles)
    • Request MethodPUT? GET? POST? (tells you the intent)
    • Status Code200? 4xx? 5xx? (but remember: 200 ≠ success)
    • Payload (Request) — what did the browser send? (here: the pairs with issuer_id: null)
    • Responsewhat came back? (here: the empty added: [] that gave it away)
  5. Compare Payload vs Response. If you sent something and it came back unchanged, you’ve found a silent failure.
  6. Now you know exactly which layer to open next: the URL tells you the route, the route tells you the controller, the controller tells you the model.

That’s the whole detective kit. The Network tab tells you where the request entered and what came back; “follow the request” tells you everything in between. Master those two together and there is no bug in a web app you cannot localise.


Recap

  • We followed one real click — selecting profile 82 on request 105 — from a React panel, through the axios service, across HTTP, past four middleware bouncers, into a thin controller, down to a diff-based model, and into MySQL — and back up again.
  • The request returned 200 OK but did nothing, because replaceSet silently continue-d on a NULL issuer_id — and the manual-add path never resolved the issuer from the profile’s brand the way the applicable-list path did.
  • The fix: give the manual path the same brand → BINs → issuer resolution (auto-fill if one issuer, picker if many) instead of skipping.
  • The transferable skills: a 200 isn’t success, read the diff response, data shape breaks code that “works,” and layer-by-layer tracing localises bugs fast — with the Network tab as your evidence locker.

Check Yourself

Don’t reread — retrieve. Answer out loud, then open each block to check.

1. Name the six hops a request makes in the generic skeleton, in order, from click to data and back.

Browser → DNS → Load Balancer → App Server → Cache → Database — then the response travels back up the same chain. In Assure PAT, the React panel lives in the Browser, the middleware + controller + model are the App Server, and Knex talks to the Database.

2. What is the load balancer's one job, and what does it NOT do?

Its only job is picking one healthy App Server to handle the request, spreading traffic across many instances. It does not run business logic, cache responses, or authenticate — those belong to other hops.

3. Why is a 200 OK not the same as "it worked"?

The HTTP status only says the request was processed without error. A handler that decides to do nothing — and does nothing successfully — still returns 200. You must read the response body to know what actually changed.

4. In the bug, which exact line in replaceSet silently threw the click away, and why?

if (!Number.isFinite(parseInt(issuer_id))) continue;. Because parseInt(null) is NaN, Number.isFinite(NaN) is false, so !false is true → it continued, skipping the pair instead of saving or erroring.

5. The middleware (validateRequest) waved issuer_id null through. Was that a bug?

No. Schema validation checks shape, not business meaning. issuer_id: null is a legal value for a multi-brand profile, so the body was genuinely well-formed. The bug was downstream, in what the model did with that legal NULL.

6. Why did the same profile save fine via the applicable-list path but vanish via the manual path?

The applicable-list path resolved the issuer from the profile’s brand (brand → BINs → issuer) before saving. The manual path skipped that resolution step entirely and sent the NULL straight to the model, which then skipped it.

7. What is the fix, in one sentence?

Give the manual path the same brand → BINs → issuer resolution the applicable-list path already had — auto-fill if exactly one issuer, show a picker if many — instead of silently continue-ing on NULL.

8. Using only the Network tab, which two fields do you compare to spot a silent failure?

The Payload (what the browser sent) versus the Response (what came back). If you sent something and it came back unchanged — here, the empty added: [] — you’ve found a silent failure.


Still Unclear?

Paste any of these into Claude to go deeper:

  • “Walk me through what a load balancer does at the packet level when two App Servers are healthy and one is down. How does it know which is unhealthy?”
  • “Show me how I’d add issuer resolution to a manual-add path in Node and Knex: given a profile with a NULL issuer_id, look up its brand, find matching BINs, and resolve to one issuer or surface a picker. Explain each query.”
  • “Give me three more examples of silent failures in a typical REST API — a 200 OK that did nothing — and how I’d catch each one by reading the response body.”

Why AI Can’t Do This For You

AI can describe the six hops and even write the replaceSet code. What it can’t do is the move that cracked this bug: standing in front of a live system that lied — a clean 200 OK and an empty panel — and asking, at each layer, “given what arrived, did you behave correctly?” That judgment, knowing which one surprising answer is the real culprit, comes from having traced real requests yourself. AI has no Network tab open on your failing click; the evidence locker is yours to read. The skill you’re building here is the instinct to follow the request and trust the body over the status — and that only forms by doing it on a system you can actually break.


Next up: Scaling & Distributed Systems — what changes when one of these boxes has to serve a million testers instead of one. 👉

Module done? Add it to today’s tracker

Saves your progress on this device.