In this article

An AI-generated error handler can return a response for every failure while giving clients the wrong information. This original JavaScript label-service example separates invalid input, a known name conflict, and an unexpected repository failure. Five executed tests check status, public error code, successful normalization, and whether rejected input reaches storage.

Define the public responses before requesting a handler

Imagine a service that creates a label from an already decoded JSON body. For this exercise, the body must be a non-array object with a string name. Trim surrounding whitespace; reject an empty result. Extra fields are ignored. A valid insertion returns status 201 with the repository’s label object. Invalid input returns 400 with error invalid_name and must not call the repository.

Two storage outcomes complete the contract. A LabelConflictError means that name already exists and maps to 409 with error label_exists. Any other repository exception maps to 500 with error internal_error. Those application strings are deliberate choices for this example; HTTP does not prescribe them.

MDN describes 400 as a client-error response and notes that an unchanged request is expected to fail again. This API chooses 400 for its name validation errors. Preserve the established contract in a real service even if a different API uses another documented validation status. MDN: 400 Bad Request

    Reproduce how one catch collapses different failures

    The generated adapter below catches everything and returns 400. A database outage becomes a claim that the caller sent a bad request. A duplicate name loses its distinct response. The raw exception message also becomes public output, tying clients to private wording and potentially exposing operational details.

    The happy path can still pass: a string name is trimmed and inserted. Empty strings also reach insert, because trimming is not validation. A null body throws before storage and gets an incidental message instead of invalid_name. Review the negative paths even when the successful response looks exactly right.

    Deliberately flawed transport-neutral JavaScript adapter
    Codejavascript
    async function createLabel(body, repo) {
      try {
        const label = await repo.insert(body.name.trim());
        return { status: 201, body: label };
      } catch (error) {
        return { status: 400, body: { error: error.message } };
      }
    }

      Separate a known conflict from an unexpected failure

      Use an explicit repository error type for the conflict the application understands. MDN defines 409 in terms of a conflict with the target resource’s current state. An already used unique label name fits this example’s chosen conflict contract; a disconnected database does not. MDN: 409 Conflict

      Do not classify errors by searching their messages for duplicate or label_exists. An unexpected error can contain those words, and a known conflict can have different private wording. In this small module, the repository and adapter share the same LabelConflictError class. A real database adapter should translate its documented constraint error into that domain error at the appropriate boundary.

      Unexpected failures remain server failures. MDN describes 500 as a generic response for an unexpected condition when a more appropriate server-error response is unavailable. Keeping that category distinct helps a client avoid presenting a misleading name-validation message during an outage. MDN: 500 Internal Server Error

        Validate before insertion and map only known errors

        The repaired adapter checks the decoded body before invoking insert. Whitespace normalization happens once, and a valid request reaches storage once. The catch distinguishes the known domain error and uses a fixed public code for everything else. No client receives the original exception message.

        This function returns a status-and-body description to keep the example runnable without a web framework. It does not send an HTTP response, set headers, parse JSON, or log errors. The route that uses it must perform those jobs. Keep protected diagnostics available through the application’s established logging path; sanitizing a public response should not erase the investigation trail.

        Repaired adapter and shared repository error type
        Codejavascript
        class LabelConflictError extends Error {}
        
        async function createLabel(body, repo) {
          const isObject = body !== null && typeof body === 'object'
            && !Array.isArray(body);
          const raw = isObject ? body.name : undefined;
          if (typeof raw !== 'string' || raw.trim().length === 0) {
            return { status: 400, body: { error: 'invalid_name' } };
          }
          const name = raw.trim();
          try {
            const label = await repo.insert(name);
            return { status: 201, body: label };
          } catch (error) {
            if (error instanceof LabelConflictError) {
              return { status: 409, body: { error: 'label_exists' } };
            }
            return { status: 500, body: { error: 'internal_error' } };
          }
        }

          Verify rejected input never reaches the write boundary

          Use a repository double with a call counter for invalid bodies. Test null, an array, an object without name, a numeric name, an empty string, and whitespace. Each must produce the exact status and public code, with zero insert calls. A status-only assertion can pass even if the service wrote an empty label first.

          For success, record the names passed to insert and expect exactly urgent from a padded input. Assert both the returned label object and the one recorded argument. That checks that the repair preserves useful behavior while strengthening the failure path; rejecting every request would otherwise appear safe.

          These tests exercise the public adapter and replace only its storage dependency. They do not mock createLabel itself. Literal expected responses remain independent of the generated branch conditions, making a wrong mapping visible instead of repeating it in the test.

            Execute the response matrix and challenge the repair

            Paste the repaired adapter and these tests into an .mjs file, then run node --test against it. The five tests passed on Node v24.15.0 during preparation. With the flawed adapter restored, four failed while the successful-insertion test still passed. That result demonstrates why the happy path alone was inadequate.

            The final test throws an ordinary Error whose message happens to equal label_exists. It must remain 500. This guards against a tempting follow-up patch that replaces the broad catch with message matching. The known-conflict test uses a private message deliberately different from the public code.

            Executable Node tests; place after createLabel
            Codejavascript
            import assert from 'node:assert/strict';
            import test from 'node:test';
            
            test('invalid bodies do not reach the repository', async () => {
              const invalid = [null, [], {}, {name: 4}, {name: ''}, {name: '   '}];
              for (const body of invalid) {
                let calls = 0;
                const repo = {insert: async () => { calls++; return {id: 1}; }};
                assert.deepEqual(await createLabel(body, repo),
                  {status: 400, body: {error: 'invalid_name'}});
                assert.equal(calls, 0);
              }
            });
            
            test('valid name is trimmed and inserted once', async () => {
              const names = [];
              const repo = {insert: async name => { names.push(name); return {id: 1, name}; }};
              assert.deepEqual(await createLabel({name: '  urgent  '}, repo),
                {status: 201, body: {id: 1, name: 'urgent'}});
              assert.deepEqual(names, ['urgent']);
            });
            
            test('known conflict has a stable public response', async () => {
              const repo = {insert: async () => { throw new LabelConflictError('private index'); }};
              assert.deepEqual(await createLabel({name: 'urgent'}, repo),
                {status: 409, body: {error: 'label_exists'}});
            });
            
            test('unexpected failure does not leak its message', async () => {
              const repo = {insert: async () => { throw new Error('secret connection string'); }};
              assert.deepEqual(await createLabel({name: 'urgent'}, repo),
                {status: 500, body: {error: 'internal_error'}});
            });
            
            test('matching text alone does not identify a conflict', async () => {
              const repo = {insert: async () => { throw new Error('label_exists'); }};
              assert.deepEqual(await createLabel({name: 'urgent'}, repo),
                {status: 500, body: {error: 'internal_error'}});
            });

              Give the coding assistant a fixed response contract

              A useful repair request supplies the existing adapter, the repository’s domain-error definition, the response matrix, and the failing tests. Ask it to preserve the successful response and public signature, reject invalid names before insertion, and change only the error classification and validation. Require an explanation for every response branch.

              After generation, inspect whether the assistant moved insertion into a validation helper, added an automatic retry, changed uniqueness rules, or returned a placeholder success on failure. Each would introduce another behavior to review. A smaller change makes it easier to match implementation branches to the contract, but size alone is no proof.

              Before wiring the adapter into a real route, verify actual HTTP status and JSON output through that route. Also check malformed JSON, authentication, request-size limits, and database constraint translation. The self-contained tests establish this adapter’s behavior, not those surrounding layers.

                Common questions about AI-generated API error handling

                Must every validation error use 400? No. This exercise explicitly chooses it. Work from your API’s published behavior and clients rather than silently changing their expectations. A reference explains status semantics; it does not replace your service contract.

                Can a 500 response be retried automatically? The status alone does not establish whether a write occurred or whether replay is safe. This adapter makes no retry or idempotency guarantee. Investigate those properties separately before adding a client retry loop.

                Is returning internal_error enough for operations? No. It is the public response policy in this example. Production diagnostics need an appropriate protected logging and correlation mechanism. The narrow repair preserves the difference between a client correction, a known conflict, and a server investigation.

                  Sources & editorial notes

                  This article was authored with AI using primary-source research and an automated technical review. It has not been reviewed by a human. Code examples are illustrative; run and verify them in your own environment.

                  1. MDN: 400 Bad Request

                    Checked October 31, 2026: 400 denotes a perceived client error. Name validation and the invalid_name code are this example’s explicit API choices.

                  2. MDN: 409 Conflict

                    Checked October 31, 2026: 409 describes a conflict with current resource state; the example maps only its known name-conflict error.

                  3. MDN: 500 Internal Server Error

                    Checked October 31, 2026: 500 describes an unexpected server condition. The sample uses a fixed public code and does not implement logging.

                  Put the reading into practice

                  Work through an existing repository, review AI suggestions, and test the decisions behind your changes.

                  Explore practice problems