“Serverless removes the infrastructure management problem and replaces it with a different set of problems. CORS is one of them.”

This is a breakdown of a number classification API built on AWS Lambda with API Gateway — the architecture decisions, the full Lambda function with input validation and error handling, the CORS preflight issue that catches most people, and the edge cases worth thinking about before they hit production.


Architecture#

GET /api/classify-number?number=42
    ↓
AWS API Gateway — routing, CORS, throttling
    ↓
Lambda Function (Python 3.9)
    ├─ Input validation
    ├─ Mathematical classification
    └─ Response formatting
    ↓
JSON response

Why Lambda for this:

  • No server to manage or patch — the function exists, runs, and costs nothing when idle
  • Pay-per-invocation — fits a reference API that gets called sporadically
  • Automatic scaling — zero to concurrent requests without configuration
  • Python runtime — straightforward for numerical operations

The tradeoff is cold starts. First invocation after a period of inactivity adds ~800ms latency. For a low-traffic API this is acceptable. For latency-sensitive production workloads you’d use provisioned concurrency or a different compute model.


Lambda Setup#

aws lambda create-function \
    --function-name number-classifier \
    --runtime python3.9 \
    --role arn:aws:iam::ACCOUNT_ID:role/lambda-execution-role \
    --handler lambda_function.lambda_handler \
    --zip-file fileb://deployment-package.zip

The --handler value maps to the file and function name exactly — lambda_function.lambda_handler means lambda_function.py containing a function named lambda_handler. Mismatch here gives you a runtime error that’s not immediately obvious from the message.


The Lambda Function#

import json
import math

def lambda_handler(event, context):
    # Handle CORS preflight — this must come before anything else
    if event.get('httpMethod') == 'OPTIONS':
        return cors_preflight_response()

    try:
        number = int(event['queryStringParameters']['number'])
    except (KeyError, TypeError):
        return error_response("Missing 'number' query parameter")
    except ValueError:
        return error_response("'number' must be a valid integer")

    result = {
        'number': number,
        'is_even': number % 2 == 0,
        'is_odd': number % 2 != 0,
        'is_prime': is_prime(number),
        'is_armstrong': is_armstrong(number),
        'is_perfect_square': is_perfect_square(number),
        'digit_sum': sum(int(d) for d in str(abs(number))),
        'is_palindrome': str(abs(number)) == str(abs(number))[::-1],
        'properties': get_properties(number)
    }

    return success_response(result)


def cors_preflight_response():
    return {
        'statusCode': 200,
        'headers': {
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Methods': 'GET, OPTIONS',
            'Access-Control-Allow-Headers': 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'
        },
        'body': ''
    }

def success_response(data):
    return {
        'statusCode': 200,
        'headers': {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*'
        },
        'body': json.dumps(data)
    }

def error_response(message):
    return {
        'statusCode': 400,
        'headers': {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*'
        },
        'body': json.dumps({'error': message})
    }

Mathematical Functions#

def is_prime(n):
    if n <= 1:
        return False
    if n <= 3:
        return True
    if n % 2 == 0 or n % 3 == 0:
        return False
    i = 5
    while i * i <= n:
        if n % i == 0 or n % (i + 2) == 0:
            return False
        i += 6
    return True


def is_armstrong(n):
    """
    Armstrong number: sum of each digit raised to the power of
    the total number of digits equals the number itself.
    153 = 1³ + 5³ + 3³ = 1 + 125 + 27 = 153
    """
    n = abs(n)
    digits = [int(d) for d in str(n)]
    return sum(d ** len(digits) for d in digits) == n


def is_perfect_square(n):
    if n < 0:
        return False
    root = math.isqrt(n)
    return root * root == n


def get_properties(n):
    props = []
    if is_armstrong(n):
        props.append('armstrong')
    if is_prime(n):
        props.append('prime')
    else:
        props.append('composite') if n > 1 else None
    props.append('even' if n % 2 == 0 else 'odd')
    return props

On the prime check: the trial division up to √n with the 6k±1 optimization is the right approach for a Lambda function handling arbitrary integer input. Sieve of Eratosthenes is faster for bulk generation but requires precomputation and memory — neither makes sense in a stateless function handling one number per invocation.


CORS — The Actual Problem#

Most Lambda/API Gateway CORS issues aren’t about the response headers. They’re about the OPTIONS preflight request.

When a browser makes a cross-origin request, it sends an OPTIONS request first to check if the server allows it. If that OPTIONS request isn’t handled and returned with the right headers, the browser blocks the actual GET before it fires.

The common mistake: adding Access-Control-Allow-Origin to the GET response only. The preflight never gets a valid response, the browser blocks the request, and you spend time debugging the wrong thing.

The fix is in the function, not just API Gateway:

# Handle OPTIONS before any other logic
if event.get('httpMethod') == 'OPTIONS':
    return cors_preflight_response()

And in API Gateway via SAM or CDK:

Resources:
  NumberClassifierAPI:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prod
      Cors:
        AllowMethods: "'GET,OPTIONS'"
        AllowHeaders: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
        AllowOrigin: "'*'"

Both layers need to be correct. API Gateway handles the routing; the Lambda handles the response. A gap at either layer reproduces the error.


Edge Cases Worth Testing Before Production#

# Non-numeric input
?number=banana      # ValueError — caught, returns 400 with clear message

# Missing parameter
?number=            # KeyError — caught, returns 400

# Negative numbers
?number=-7          # is_prime returns False, digit_sum uses abs()

# Zero
?number=0           # is_prime returns False, is_armstrong returns True (0⁰ edge case)

# Large numbers
?number=9999999999  # Prime check runs to sqrt — ~100k iterations, fine for Lambda

The timeout protection pattern:

remaining_time = context.get_remaining_time_in_millis()
if remaining_time < 1000:
    return error_response("Approaching timeout limit")

Add this before expensive computation if you’re accepting unbounded input. Lambda default timeout is 3 seconds — a sufficiently large prime check will hit it.


Sample Response#

{
  "number": 371,
  "is_even": false,
  "is_odd": true,
  "is_prime": false,
  "is_armstrong": true,
  "is_perfect_square": false,
  "digit_sum": 11,
  "is_palindrome": false,
  "properties": ["armstrong", "composite", "odd"]
}

Interesting test cases: 153 (Armstrong), 1729 (Hardy-Ramanujan — smallest number expressible as sum of two cubes in two ways), 2 (only even prime).


Performance#

MetricResult
Average response time~50ms
Cold start~800ms
Monthly cost$0 within free tier

Cold start is the only number worth paying attention to. 50ms warm response is fine for this use case. If you need consistent sub-100ms including cold starts, provisioned concurrency resolves it at a cost increase.


What I’d Do Differently#

Add rate limiting at API Gateway. An open Lambda endpoint with no throttling is a cost risk — someone hammers it with large number inputs and you’re paying for the compute. API Gateway usage plans take 10 minutes to configure.

Cache results for common inputs. Numbers like primes under 1000 get requested repeatedly. A simple ElastiCache layer or even Lambda-level memoization with a TTL would cut invocations significantly for a high-traffic version.

Input bounds validation. Accepting unbounded integers means accepting arbitrarily expensive prime checks. Set a maximum input size explicitly and return a 400 for anything above it.


Source#

Full code and deployment scripts on GitHub .


Tags#

#AWS #Serverless #Lambda #Infrastructure #Python #API


About the Author#

Elijah Udom (elijahu) is an Infrastructure & Cloud Engineer based in Lagos, Nigeria. AWS, Kubernetes, eBPF security, AI/ML infrastructure. Building in the open.

Elijah Udom


← Previous: Nginx on AWS EC2 | Next: FastAPI →