Modifying Requests and Responses — Simulating Errors, Delays and Edge Cases

Sometimes you want mostly-real API behaviour with one specific change: add a 5-second delay to test timeout handling, inject a 503 on the third request to test retry logic, modify a response field to test how the UI handles unexpected values, or strip an authentication header to test unauthorised access. The req.continue() method lets you intercept a request, optionally modify it, send it to the real server, and then modify the real response before the browser receives it.

Modifying Requests and Responses — Surgical Precision

Modification sits between spying (no changes) and stubbing (complete replacement). You keep the real server interaction but alter specific aspects.

// ── MODIFYING RESPONSES: req.continue() with callback ──

// Change one field in the real response
cy.intercept('GET', '/api/products', (req) => {
  req.continue((res) => {
    // Real response arrives — modify before browser receives it
    res.body[0].price = 0.01;            // Override first product's price
    res.body[0].name = 'SALE ITEM';     // Override first product's name
  });
}).as('modifiedProducts');

cy.visit('/shop');
cy.wait('@modifiedProducts');
cy.get('.product-card').first().should('contain.text', '$0.01');


// ── INJECTING NETWORK DELAYS ──

// Add 5 seconds to real API response time
cy.intercept('GET', '/api/products', (req) => {
  req.continue((res) => {
    res.delay = 5000;  // 5 second delay on real response
  });
}).as('delayedProducts');

// Test timeout handling
cy.visit('/shop');
cy.get('[data-cy="loading"]').should('be.visible');
// If the app has a 3-second timeout, it should show an error before data arrives


// ── SIMULATING INTERMITTENT FAILURES ──

let callCount = 0;
cy.intercept('GET', '/api/products', (req) => {
  callCount++;
  if (callCount <= 2) {
    // First 2 calls fail — test retry logic
    req.reply({
      statusCode: 503,
      body: { error: 'Service temporarily unavailable' },
    });
  } else {
    // Third call succeeds — app should recover
    req.continue();
  }
}).as('flakyApi');

cy.visit('/shop');
// Verify retry mechanism works — products eventually appear
cy.get('.product-card', { timeout: 15000 }).should('have.length.greaterThan', 0);


// ── MODIFYING REQUEST HEADERS ──

cy.intercept('GET', '/api/admin/*', (req) => {
  // Strip the auth header to test unauthorised access handling
  delete req.headers['authorization'];
}).as('noAuth');


// ── MODIFYING REQUEST BODY ──

cy.intercept('POST', '/api/orders', (req) => {
  // Inject an extra field the UI does not send
  req.body.testMode = true;
  req.body.testId = `cypress_${Date.now()}`;
  req.continue();  // Send modified request to real server
}).as('taggedOrder');


// ── SIMULATING NETWORK ERRORS ──

// Complete network failure (server unreachable)
cy.intercept('GET', '/api/products', { forceNetworkError: true }).as('networkError');

cy.visit('/shop');
cy.wait('@networkError');
cy.get('[data-cy="network-error"]')
  .should('be.visible')
  .and('contain.text', 'Unable to connect');


// ── CONDITIONAL MODIFICATION based on request content ──

cy.intercept('POST', '/api/search', (req) => {
  if (req.body.query === '') {
    // Empty search — return error instead of forwarding to server
    req.reply({
      statusCode: 400,
      body: { error: 'Search query cannot be empty' },
    });
  } else {
    // Non-empty search — forward to real server
    req.continue();
  }
}).as('search');
Note: The req.continue() method is the bridge between spying and stubbing. Calling req.continue() without a callback forwards the request to the real server unchanged (same as spying). Calling req.continue((res) => { ... }) with a callback forwards the request to the real server AND lets you modify the response before the browser receives it. Calling req.reply({ ... }) short-circuits the request entirely and returns your fake response without contacting the server (same as stubbing). These three options — continue, continue-with-callback, and reply — give you full control over the request lifecycle.
Tip: The intermittent failure pattern (fail N times, then succeed) is the best way to test retry mechanisms. Most applications implement retry logic for transient errors (503, 429, network timeout), but this logic is nearly impossible to test against real APIs because you cannot make a production server fail on demand. With cy.intercept() and a counter variable, you can simulate exact failure-then-recovery sequences and verify that the retry UI (retry button, auto-retry timer, error-then-recovery transition) works correctly.
Warning: forceNetworkError: true simulates a complete network failure — the browser receives no response at all, as if the server were unreachable. This is different from a 500 status code (the server responded with an error). Applications handle these differently: network errors trigger the browser’s built-in error handling (no HTTP response to inspect), while 500 errors still provide a response body with error details. Test both scenarios — they exercise different error handling code paths.

Common Mistakes

Mistake 1 — Using req.reply() when req.continue() is needed

❌ Wrong: req.reply({ statusCode: 200, body: req.body }) — returns a fake response containing the request body instead of forwarding to the server.

✅ Correct: req.continue((res) => { res.body.modified = true; }) — forwards to the real server and modifies only the response.

Mistake 2 — Forgetting to call req.continue() after modifying request headers

❌ Wrong: cy.intercept('GET', '/api/data', (req) => { req.headers['x-test'] = 'true'; }) — the request hangs because no reply or continue was called.

✅ Correct: cy.intercept('GET', '/api/data', (req) => { req.headers['x-test'] = 'true'; req.continue(); }) — modify then forward.

🧠 Test Yourself

You need to test that your application’s retry mechanism recovers from a transient 503 error. How do you simulate “fail twice, succeed on third attempt”?