Agent Skills in Claude Code and Visual Studio Code: Setup, Benefits, Risks, and a Step-by-Step Example

Image
 AI coding assistants are useful for one-off questions, but many development tasks are not one-off tasks. A team may follow the same testing procedure before every release, use a specific format for code reviews, or require certain checks whenever a database migration is created. Repeating those requirements in every prompt is inefficient. Putting all of them into a permanent instruction file is not always ideal either, because the assistant may receive irrelevant information for tasks that do not need it. This is the problem that Agent Skills are designed to solve. A skill packages reusable instructions, supporting files, examples, and optional scripts into a folder. Claude Code or GitHub Copilot in Visual Studio Code can discover that folder and load its contents when the current task matches the skill’s purpose. Both environments support the open Agent Skills format, which makes it possible to share some skills across tools. This guide explains what skills are, how they differ ...

The Happy Path Trap: Reviewing Error Handling and Edge Cases

The Happy Path Trap: Reviewing Error Handling and Edge Cases

"Copilot completed it. It works."

When you see this in a PR description, experienced reviewers put on their "failure mode" glasses. Because "it works" almost always means "it works on the happy path" — when the user provides correct input, the network responds immediately, the database is responsive, and all external services are alive and well. Production has a very different set of conditions.

Understanding the Happy Path vs. Reality

The happy path is the flow where everything goes right. User provides valid input. Network is responsive. Database returns results quickly. External APIs respond with the expected format. No timeouts. No concurrent requests causing race conditions. No edge cases.

Code that works only on the happy path is a disaster waiting to happen in production. Here's why:

Look at AI-generated code like this:

async function getUserReport(userId: string) {
  const user = await userService.findById(userId);
  const orders = await orderService.getRecentOrders(userId);
  const report = reportBuilder.build(user, orders);
  return report;
}

It's short, clean, and the logic appears correct. But what happens in production?

  • If `userId` doesn't exist → `user` is null → NPE when accessing user properties
  • If `orderService` times out → unhandled Promise rejection crashes the request
  • If `reportBuilder` fails → error propagates without context
  • If `userId` is an empty string → unpredictable behavior
  • If the database query is slow → request hangs

This code doesn't handle the real world where things fail constantly and in combinations.

Three Areas AI Systematically Misses

Area 1: Null/Undefined Returns

Functions often return null when "no value is found." But the possibility must be explicitly expressed and must be handled at every call site:

// Before: returns null, but caller doesn't check
public User findByEmail(String email) {
    return userRepository.findByEmail(email); // may return null, but no indication
}

// Caller doesn't check
User user = userService.findByEmail(email);
String name = user.getName(); // NPE possible if user is null
String email = user.getEmail(); // Another NPE possible

Better design makes nullability explicit in the type contract:

// After: type shows nullability explicitly
public Optional<User> findByEmail(String email) {
    return userRepository.findByEmail(email);
}

// Caller must handle
Optional<User> user = userService.findByEmail(email);
String name = user.map(User::getName).orElse("Unknown");
String email = user.map(User::getEmail).orElse("");

// Or throw an exception if null is unexpected
User user = userService.findByEmail(email)
    .orElseThrow(() -> new UserNotFoundException(email));

Area 2: Unhandled Asynchronous Errors

async function loadDashboard() {
  const userData = await fetchUser();     // what if fails?
  const orderData = await fetchOrders();  // what if fails?
  const statsData = await fetchStats();   // what if fails?

  renderDashboard(userData, orderData, statsData);
}

If any call fails, the entire dashboard is unrendered. There's no error message to the user. Just silence or console warnings that the user never sees.

Area 3: Assuming External Systems Work

External APIs, payment gateways, email services — these fail constantly. Reviewers check whether code handles timeouts, temporary failures, rate limiting, and maintenance windows.

Exception Handling Antipatterns

Antipattern 1: Catching and Doing Nothing

try {
    emailService.send(userId, message);
} catch (Exception e) {
    // ignore
}

This is dangerous and insidious. If sending fails, absolutely no one knows. No log, no alert, no error message. The system silently fails. The user doesn't know. The ops team doesn't know. The email was silently lost.

Better approach: Log the error, and decide whether to propagate it or use a fallback strategy.

Antipattern 2: Catching Everything With One Handler

try {
  validateOrder(data);
  await orderRepository.save(data);
  await paymentService.charge(order);
  await emailService.sendConfirmation(order);
} catch (error) {
  return { success: false, message: 'An error occurred.' };
}

But these are fundamentally different errors:

  • Validation failure → user error → 400 Bad Request
  • DB save failure → server error → 500, needs retry
  • Payment failure → specific error → 402 Payment Required, cancel order
  • Email failure → order succeeded, only email failed, don't roll back

Handling them all with one generic message hides what actually went wrong.

Antipattern 3: Exposing Internal Information

catch (error) {
    return { error: error.getMessage() }; // DB connection info exposed!
}

If the database error is `Connection refused: jdbc:postgresql://internal-db:5432/prod`, you've just exposed your entire infrastructure to an attacker.

Better approach: Log details on the server. Give a generic message to the user.



Questions to Discover Edge Cases

When reviewing code, systematically ask:

"What if the value is not what I expected?"

  • What if null or undefined?
  • What if empty (empty string, empty array)?
  • What if 0 or negative?
  • What if very large (millions, billions)?
  • What if special characters or Unicode?

"What if things happen at the same time?"

Race conditions often don't show in tests because tests are single-threaded. Two requests reach the same code simultaneously:

async function useCoupon(couponId, userId) {
  const coupon = await couponRepository.findById(couponId);
  if (coupon.usedAt !== null) throw new CouponAlreadyUsed();

  // RACE CONDITION: two requests both pass the check
  await couponRepository.markAsUsed(couponId, userId);
}

Two requests check → both pass → both try to use the coupon → one coupon is used twice → revenue loss.

"What if the outside world doesn't cooperate?"

  • What if a timeout occurs?
  • What if the connection is lost?
  • What if the response format is different?
  • What if there's no response at all?
  • What if the API is in maintenance mode?

Defensive coding means trusting nothing from external systems.

Edge Case Reviewer Checklist

□ Inputs
  □ Can null or undefined appear?
  □ Are empty values (empty string, empty array, 0) handled?
  □ Can negative or unusually large values appear?
  □ Are strings validated for length and content?

□ External dependencies
  □ Is there handling for DB/API call failure?
  □ Is a timeout configured?
  □ Is the external response shape validated?
  □ What if the service is down?

□ Concurrency
  □ What happens if two requests arrive simultaneously?
  □ Can intermediate state be changed elsewhere?
  □ Are there race conditions?

□ State transitions
  □ Is the operation idempotent (safe to retry)?
  □ Can the previous state be restored if operation fails?
  □ What if another process modifies state in between?

Practical Examples: Real Edge Cases That Hurt Production

Case 1: The Null Email Bug

A service sends a welcome email to new users. The code doesn't check if email is null. Ninety-nine percent of users have email. One percent get null from a legacy import. The email service crashes on null, logs errors, and that 1% never gets their welcome email or gets an error when trying to login. This cascades into support tickets.

Case 2: The Race Condition in Inventory

A store has 1 item in stock. Two customers buy it simultaneously. The code checks inventory, finds 1, sells to both. Now the store is obligated to deliver 2 items but has 0. Returns and refunds follow. The fix requires database transactions or atomic operations — things only visible when you ask "what if two requests arrive at the same time?"

Case 3: The Silent Payment Failure

A payment API call fails (network timeout). The code has a try-catch that logs the error but doesn't fail the order. The customer thinks they paid but never actually did. Their order never ships. They get charged by their credit card company for a chargeback. Ops gets an angry call.

The fix would have been visible if someone asked "what happens when the external API fails?"

Building a Mental Model

Expert reviewers develop an intuitive sense of where edge cases hide. They think in fault trees:

  • Null/undefined: Every variable could be null. The database returns null. The API returns empty. The cache key is missing.
  • Concurrency: Every operation could happen twice. Two requests. Two processes. Two servers.
  • External failures: Every external system will fail. Eventually. Often at the worst time.
  • Boundaries: Every boundary has edge cases. 0, negative, maximum value, infinity, NaN.

Develop this mental model by asking those questions consistently, across multiple PRs, until they become automatic.

Concurrency Patterns: A Frequent Blind Spot

Many developers write code that's correct in single-threaded scenarios but breaks with concurrency:

// Seemingly safe - but not thread-safe
public boolean useDiscount(Discount discount) {
    if (discount.getUsageCount() < discount.getMaxUsages()) {
        discount.setUsageCount(discount.getUsageCount() + 1);
        discountRepository.save(discount);
        return true;
    }
    return false;
}

// Two concurrent requests:
// Request 1: checks count (0), passes check
// Request 2: checks count (0), passes check  ← RACE CONDITION
// Request 1: saves count (1)
// Request 2: saves count (1) ← Same value saved twice
// Discount is now used twice when max is 1

Solution: Use database-level constraints:

@Lock(LockModeType.PESSIMISTIC_WRITE)  // Lock at database level
public Discount findAndLockDiscount(Long discountId) {
    return discountRepository.findById(discountId).orElse(null);
}

public boolean useDiscount(Discount discount) {
    if (discount.getUsageCount() < discount.getMaxUsages()) {
        discount.setUsageCount(discount.getUsageCount() + 1);
        discountRepository.save(discount);
        return true;
    }
    return false;
}

Build the Habit

When reviewing code, don't stop after confirming the happy path works. Ask one more time: What if the value is null? What if two requests arrive together? What if the external API fails or times out? What if the database is slow?

That one extra question prevents production incidents at 3 AM. That consistency makes you a reviewer others respect and learn from.

Building Your Mental Model of Failure

Expert reviewers develop intuition about failure modes. They ask the same questions across different codebases because the failure patterns are universal:

  • Input validation: Every boundary condition has edge cases
  • State management: Every state has a transition that can fail
  • External systems: Every external system will eventually fail
  • Concurrency: Every concurrent operation has race conditions
  • Resource exhaustion: Every resource pool can be exhausted

Learn these patterns. When reviewing code, consciously check each one. Over time, it becomes automatic.

Comments

Popular posts from this blog

Claude Code Multi-Agent Setup for Beginners: Models, Roles, and Your First Project

The Limits and Future of Vibe Coding

How to Test and Deploy Your First Vibe-Coded App