The API Responded. Why Did the UI Fail? HTTP, JSON, and Retries — Vibe Coding Lesson 4

Vibe Coding Fundamentals · Season 1 · Lesson 4

Language: English · 한국어

An API call is a contract: a client sends a request, a server responds, and your app decides whether the response is usable. When a button fails to show data, first separate a connection problem from an HTTP error and a malformed response. They demand different fixes.

In Lesson 3, the server—not a login badge—was the authority for authentication. This week’s question is “The API responded. Why did my screen still fail?” We’ll diagnose it with a local, fictional seat-count API.

1. API, HTTP, and JSON are different layers

  • API: An interface contract between programs. Its URL matters, but so do inputs, outputs, failures, and the meaning of the operation.
  • HTTP request: The method (GET, for example), URL, headers, and sometimes a body. Our GET /api/seats request has no body.
  • HTTP response: A status code, headers, and usually a body. A 200 response does not guarantee that the body matches the app’s expectations.
  • JSON: A data format, such as {"available":2}. It isn’t a database or a transport protocol. Reading it with response.json() can fail.
  • fetch(): An HTTP 404 or 503 normally fulfills the fetch promise with a Response. Check response.ok and validate the data separately.

A practical decision rule: Did a response arrive? Was its HTTP status acceptable? Could we parse its body? Did the parsed data match the contract? This sequence makes AI-generated API code much easier to review.

2. A mental model: four boundaries between a button and a result

  1. UI: A click starts the action and the page shows Loading....
  2. Browser → server: A GET request goes to /api/seats?case=ok.
  3. Server → browser: The server replies with an HTTP status, headers, and a body. Status and valid JSON are not the same test.
  4. Browser → UI: Only after response.ok, JSON parsing, and integer validation does the page show a seat count.
Where it failsWhat to inspect
Before a responseIs the request in DevTools Network? URL, connectivity, or CORS?
At the HTTP boundaryIs the status 404, 401, or 503?
While reading the bodyIs the response actually parseable JSON, with the expected Content-Type?
After parsingDoes available exist and contain an integer?

An API 404 is not necessarily the same problem as a page that breaks only on refresh. Compare this with the SPA refresh 404 Clinic before changing routing rules.

3. A free, local lab with four deliberately different responses

The seat count 2 is fictional test data. This is a read-only learning server: it books nothing and stores nothing. No paid service, subscription, credit card, domain, account, or real data is needed.

  1. Install the free Node.js runtime and confirm node -v in your terminal.
  2. Copy the entire example below into one file called server.js, not server.js.txt.
  3. From that folder, run node server.js. Open http://127.0.0.1:3200 in a browser. If port 3200 is already in use, identify the other process first.
  4. Choose each scenario, click Check seats, and compare the page with the /api/seats entry in DevTools Network.
// Synthetic read-only API lab. Local learning use only.
const http = require('node:http');

const page = `<!doctype html>
<html lang="en"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>API response lab</title>
<main style="font:1rem/1.6 system-ui;max-width:30rem;margin:3rem auto;padding:0 1rem">
  <h1>Fictional seat availability</h1>
  <label for="scenario">Server scenario</label>
  <select id="scenario">
    <option value="ok">200 / valid JSON</option>
    <option value="busy">503 / server unavailable</option>
    <option value="broken">200 / invalid JSON</option>
    <option value="slow">Slow response / timeout</option>
  </select>
  <button id="load">Check seats</button>
  <p id="result" role="status" aria-live="polite">Not requested yet.</p>
</main>
<script>
const button = document.querySelector('#load');
const result = document.querySelector('#result');
button.addEventListener('click', async () => {
  button.disabled = true;
  result.textContent = 'Loading...';
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 1500);
  try {
    const mode = document.querySelector('#scenario').value;
    const response = await fetch('/api/seats?case=' + mode, {
      signal: controller.signal
    });
    if (!response.ok) {
      result.textContent = 'HTTP ' + response.status + ': no seats shown';
      return;
    }
    const data = await response.json();
    if (!Number.isInteger(data.available)) {
      throw new Error('Unexpected response shape');
    }
    result.textContent = 'Available: ' + data.available;
  } catch (error) {
    result.textContent = error.name === 'AbortError'
      ? 'Timed out. The server outcome is unknown.'
      : error instanceof SyntaxError
        ? 'Invalid JSON from server.'
        : 'Network or data error: ' + error.message;
  } finally {
    clearTimeout(timer);
    button.disabled = false;
  }
});
</script></html>`;

http.createServer((req, res) => {
  const url = new URL(req.url, 'http://127.0.0.1:3200');
  if (req.method === 'GET' && url.pathname === '/') {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    return res.end(page);
  }
  if (req.method === 'GET' && url.pathname === '/api/seats') {
    const mode = url.searchParams.get('case');
    const headers = { 'Content-Type': 'application/json; charset=utf-8',
      'Cache-Control': 'no-store' };
    if (mode === 'busy') {
      res.writeHead(503, { ...headers, 'Retry-After': '5' });
      return res.end(JSON.stringify({ error: 'temporarily_unavailable' }));
    }
    if (mode === 'broken') {
      res.writeHead(200, headers);
      return res.end('{"available":'); // Intentionally malformed JSON.
    }
    const send = () => {
      res.writeHead(200, headers);
      res.end(JSON.stringify({ available: 2 }));
    };
    if (mode === 'slow') return setTimeout(send, 2200);
    return send();
  }
  res.writeHead(404, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ error: 'not_found' }));
}).listen(3200, '127.0.0.1', () => {
  console.log('Open http://127.0.0.1:3200');
});

The server listens only on 127.0.0.1, and the page calls its own origin. The busy option intentionally returns 503; broken returns invalid JSON with status 200; slow responds after 2.2 seconds. The browser cancels waiting after 1.5 seconds. Aborting a browser request does not prove the server stopped its work.

4. What counts as success, failure, or an edge case?

ScenarioNetwork observation → page result
ok200 with valid JSON
Page: Available: 2
busy503 with an error body
Page: HTTP 503: no seats shown
broken200 with invalid JSON
Page: Invalid JSON from server.
slowServer waits about 2.2 s; client aborts at 1.5 s
Page: Timed out. The server outcome is unknown.
Stop the server, then click in the still-open pageConnection error, usually no HTTP response
Page: Network or data error
Open /api/unknown in the address bar404
Page: JSON not_found

Loading... is a UI state, not an HTTP status. 503 indicates temporary unavailability. This lab sends Retry-After: 5, but that header does not automatically make the browser retry. Stop the server with Ctrl+C.

5. Five ways an AI-built API integration goes wrong

  • ① Treating a fulfilled fetch as business success: A 404 or 503 still has a response. Without checking response.ok, the UI can report success for an error.
  • ② Assuming every body is JSON: A gateway might return an HTML error page, empty body, or malformed JSON. Parsing fails even when the request reached a server.
  • ③ Ignoring response shape: The UI expects available, but the API starts returning remaining. A parseable body can still violate the contract.
  • ④ Calling a timeout a definite failure: The client stopped waiting; the server may already have performed the action. Retrying a charge or create operation could duplicate it.
  • ⑤ Treating CORS, HTTP status, and routing as one bug: This same-origin lab needs no CORS configuration. Cross-origin APIs need server permission; mode: 'no-cors' does not make their responses readable.

6. Ask AI for observable states, not just a working-looking button

Weak prompt: “Make this button fetch data from the API. Keep retrying if it fails.”

Better prompt: “Inspect the existing API’s method, URL, request, and response JSON contract before coding. Distinguish loading, success, HTTP 4xx/5xx, network failure, invalid JSON, wrong response shape, and timeout. Check response.ok, clean up aborted requests, and give me reproducible DevTools Network checks. Do not add automatic retries until the operation is confirmed read-only and idempotent, and duplicate side effects are ruled out. Use synthetic data, never real customer records or tokens.”

Review questions: “Does a 503 appear as success? What happens with 200 and broken JSON? Can we tell whether the server executed a request after a timeout?”

7. When is retrying safe?

GET is defined by HTTP as safe (read-only) and idempotent. If a seat-checking endpoint really follows those semantics, checking again does not create another booking. But an unanswered POST that creates a reservation or charges a card may already have succeeded. Don’t automatically repeat a write request without a server-side duplicate-prevention design, such as idempotency keys and result lookup. Uncontrolled retries can also amplify an outage.

8. Undo and recover

  1. Reset the lab: Stop it with Ctrl+C and restart node server.js. There is no database or durable state to restore.
  2. Undo an AI edit: Keep a copy of the original server.js before changing it. Compare the diff and restore the known-working version if needed. A later lesson introduces Git for this.
  3. On a real service: Capture the request URL, status, response body, and time before changing code. Make the smallest justified fix or roll back, and independently verify whether any write operation already took effect.

9. When this approach is the wrong one

Don’t build an API server just to display values already available in a static page. And don’t deploy this teaching server as a real booking, payment, or login backend: it lacks authentication, permissions, persistence, input controls, and operational safeguards. A mock is useful for a UI prototype, but passing a mock test is not proof that the network integration works.

10. Quick review, with answers

  1. Q: If fetch() returns, did the API succeed? Not necessarily. Check HTTP status, JSON parsing, and the response shape.
  2. Q: Can a 200 response still break the UI? Yes. Try broken in the lab.
  3. Q: Does timeout mean the server did nothing? No. You know the client did not obtain a timely answer, not whether the operation executed.
  4. Q: Is retrying every GET always fine? No. Confirm the server actually honors safe semantics and consider traffic, load, and policy.

11. What’s next?

Lesson 2 located the owners of data. Lesson 3 located the authority for login. Now that we can evaluate an API response, Lesson 5 will cover databases, schemas, IDs, and migrations: what must happen for a seat count to survive a restart? For a related diagnostic, see the Clinic on data disappearing after refresh.

Official references


다른 글 보기 · 주제 탐색 · 작성자와 편집 기준 · 문의·정정 요청

RUDA DIRECTOR에서 더 알아보기

지금 구독하여 계속 읽고 전체 아카이브에 액세스하세요.

계속 읽기