Overview
HTTP methods and status codes form the foundation of client-server communication on the web. HTTP methods define the action a client wants to perform on a resource, while status codes communicate the result of that request. The HTTP/1.1 specification defines nine methods and organizes status codes into five classes, each serving specific purposes in the request-response cycle.
HTTP methods describe the desired action to perform on a resource identified by a URI. Each method has defined semantics that determine how servers should process requests and how clients should handle responses. The method indicates intent—whether the client wants to retrieve data, modify a resource, or remove it entirely. Safe methods make no modifications, while idempotent methods produce the same result regardless of how many times they execute.
Status codes appear in the first line of HTTP responses and indicate whether a request succeeded, failed, or requires additional action. The first digit determines the class: 1xx for informational responses, 2xx for success, 3xx for redirection, 4xx for client errors, and 5xx for server errors. Each code carries specific semantic meaning that clients use to determine how to proceed.
require 'net/http'
uri = URI('https://api.example.com/users/123')
response = Net::HTTP.get_response(uri)
puts "Status: #{response.code} #{response.message}"
# => Status: 200 OK
case response.code.to_i
when 200..299
puts "Success: #{response.body}"
when 400..499
puts "Client error"
when 500..599
puts "Server error"
end
The relationship between methods and status codes determines API behavior. A GET request that finds a resource returns 200 OK, while the same request for a missing resource returns 404 Not Found. POST creates resources and typically returns 201 Created with a Location header, while PUT replaces existing resources and returns 200 OK or 204 No Content. DELETE removes resources and returns 204 No Content or 200 OK with a response body describing the deletion.
Key Principles
HTTP methods divide into categories based on their properties. Safe methods make no modifications to server state. GET, HEAD, OPTIONS, and TRACE are safe—clients can call them repeatedly without concern for side effects. This property allows caching, prefetching, and retrying without risk. Idempotent methods produce identical results when repeated. PUT, DELETE, GET, HEAD, OPTIONS, and TRACE are idempotent. Calling DELETE on a resource twice produces the same outcome as calling it once—the resource no longer exists. POST is neither safe nor idempotent because each request typically creates a new resource.
Cacheable methods allow responses to be stored and reused. GET, HEAD, and POST are cacheable, though POST caching requires explicit cache controls. Caching reduces server load and improves response times by serving stored responses for identical requests. Cache-Control headers determine how long responses remain valid and under what conditions clients must revalidate them.
The request-response cycle follows a defined pattern. Clients send requests containing a method, URI, headers, and optional body. Servers process the request, perform the indicated action, and return a status code with headers and optional body. The status code class indicates the general outcome, while the specific code provides details.
# Status code classes and their meanings
class HTTPResponse
def self.status_class(code)
case code
when 100..199 then :informational
when 200..299 then :success
when 300..399 then :redirection
when 400..499 then :client_error
when 500..599 then :server_error
else :unknown
end
end
end
HTTPResponse.status_class(200) # => :success
HTTPResponse.status_class(404) # => :client_error
Content negotiation determines response format. Clients send Accept headers indicating preferred media types, while servers respond with Content-Type headers specifying the actual format. A client requesting Accept: application/json receives JSON responses, while Accept: text/html receives HTML. Status code 406 Not Acceptable indicates the server cannot produce any acceptable format.
Conditional requests optimize transfers by using validators. ETag headers contain resource version identifiers, while Last-Modified headers indicate update times. Clients include If-None-Match or If-Modified-Since headers in subsequent requests. If the resource has not changed, servers return 304 Not Modified without a body, saving bandwidth. If-Match and If-Unmodified-Since enable optimistic locking for updates.
require 'net/http'
uri = URI('https://api.example.com/data')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
# Initial request
request = Net::HTTP::Get.new(uri)
response = http.request(request)
etag = response['ETag']
data = response.body
# Conditional request
request = Net::HTTP::Get.new(uri)
request['If-None-Match'] = etag
response = http.request(request)
if response.code == '304'
puts "Using cached data"
# Use previously stored data
else
puts "Data changed, updating cache"
data = response.body
end
Method semantics define expected behavior. GET retrieves representations without modification. HEAD retrieves only headers, useful for checking resource existence or metadata without transferring the body. POST submits data for processing, often creating new resources. PUT replaces entire resources with provided representations. PATCH applies partial modifications. DELETE removes resources. OPTIONS describes communication options for resources. TRACE performs message loop-back tests. CONNECT establishes tunnels for secure communication.
Status code semantics convey precise outcomes. 200 OK indicates success with a response body. 201 Created signals successful resource creation, typically including a Location header pointing to the new resource. 204 No Content indicates success without a response body. 301 Moved Permanently redirects permanently with a Location header. 302 Found redirects temporarily. 400 Bad Request signals malformed requests. 401 Unauthorized requires authentication. 403 Forbidden denies access regardless of authentication. 404 Not Found indicates missing resources. 500 Internal Server Error reports unrecoverable server failures. 503 Service Unavailable indicates temporary unavailability, often with a Retry-After header.
Ruby Implementation
Ruby provides multiple approaches for HTTP communication. The standard library includes Net::HTTP for basic HTTP operations, while popular gems offer higher-level abstractions. Web frameworks like Rails and Sinatra build on these foundations to simplify API development.
Net::HTTP handles low-level HTTP operations. It supports all standard methods and provides access to headers, status codes, and response bodies. The library requires manual connection management and lacks advanced features like automatic redirects or connection pooling.
require 'net/http'
require 'json'
# GET request
uri = URI('https://api.example.com/users')
response = Net::HTTP.get_response(uri)
users = JSON.parse(response.body) if response.code == '200'
# POST request with JSON payload
uri = URI('https://api.example.com/users')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Post.new(uri.path)
request['Content-Type'] = 'application/json'
request.body = JSON.generate({
name: 'Alice',
email: 'alice@example.com'
})
response = http.request(request)
case response.code
when '201'
new_user = JSON.parse(response.body)
location = response['Location']
puts "Created user at #{location}"
when '400'
errors = JSON.parse(response.body)
puts "Validation errors: #{errors}"
when '500'
puts "Server error"
end
HTTP.rb gem provides a chainable interface with connection pooling and automatic redirects. The API emphasizes immutability and composability, making request construction more expressive.
require 'http'
# GET with query parameters
response = HTTP.get('https://api.example.com/search', params: {
q: 'ruby',
limit: 10
})
if response.status.success?
results = response.parse
end
# POST with authentication
response = HTTP.auth("Bearer #{token}")
.post('https://api.example.com/data', json: {
key: 'value'
})
# Handle different status codes
case response.code
when 200..299
data = response.parse
when 401
# Refresh token and retry
when 404
# Resource not found
when 500..599
# Server error, implement retry with backoff
end
Faraday abstracts HTTP clients through a middleware architecture. Adapters support different backends, while middleware handles concerns like logging, caching, and retry logic.
require 'faraday'
require 'faraday/retry'
conn = Faraday.new(url: 'https://api.example.com') do |f|
f.request :json
f.request :retry, max: 3, interval: 0.5,
retry_statuses: [500, 502, 503, 504]
f.response :json
f.response :raise_error
f.adapter Faraday.default_adapter
end
begin
# GET request
response = conn.get('/users/123')
user = response.body
# PUT request - replace entire resource
response = conn.put('/users/123') do |req|
req.body = {
name: 'Bob',
email: 'bob@example.com',
role: 'admin'
}
end
# PATCH request - partial update
response = conn.patch('/users/123') do |req|
req.body = { role: 'moderator' }
end
# DELETE request
response = conn.delete('/users/123')
puts "Deleted" if response.status == 204
rescue Faraday::ResourceNotFound => e
puts "User not found: #{e.response[:status]}"
rescue Faraday::ServerError => e
puts "Server error: #{e.response[:status]}"
end
Rails controllers map HTTP methods to controller actions through routing. REST conventions associate methods with CRUD operations—GET for read, POST for create, PUT/PATCH for update, DELETE for destroy. The framework automatically parses request bodies and sets appropriate status codes.
class UsersController < ApplicationController
# GET /users
def index
@users = User.all
render json: @users, status: :ok
end
# GET /users/:id
def show
@user = User.find_by(id: params[:id])
if @user
render json: @user, status: :ok
else
render json: { error: 'Not found' }, status: :not_found
end
end
# POST /users
def create
@user = User.new(user_params)
if @user.save
render json: @user, status: :created, location: @user
else
render json: { errors: @user.errors }, status: :unprocessable_entity
end
end
# PUT /users/:id - replace entire resource
def update
@user = User.find_by(id: params[:id])
return render json: { error: 'Not found' }, status: :not_found unless @user
if @user.update(user_params)
render json: @user, status: :ok
else
render json: { errors: @user.errors }, status: :unprocessable_entity
end
end
# PATCH /users/:id - partial update
def update
@user = User.find_by(id: params[:id])
return render json: { error: 'Not found' }, status: :not_found unless @user
if @user.update(user_params)
head :no_content # 204 No Content
else
render json: { errors: @user.errors }, status: :unprocessable_entity
end
end
# DELETE /users/:id
def destroy
@user = User.find_by(id: params[:id])
return head :not_found unless @user
@user.destroy
head :no_content # 204 No Content
end
private
def user_params
params.require(:user).permit(:name, :email, :role)
end
end
Sinatra provides minimal routing with explicit method handlers. Each route specifies the HTTP method, path pattern, and handler block. The framework exposes status codes and headers through helper methods.
require 'sinatra'
require 'json'
# GET route
get '/users/:id' do
user = User.find(params[:id])
if user
content_type :json
status 200
user.to_json
else
status 404
{ error: 'User not found' }.to_json
end
end
# POST route
post '/users' do
data = JSON.parse(request.body.read)
user = User.new(data)
if user.save
status 201
headers 'Location' => "/users/#{user.id}"
user.to_json
else
status 422
{ errors: user.errors }.to_json
end
end
# PUT route - full replacement
put '/users/:id' do
user = User.find(params[:id])
halt 404, { error: 'Not found' }.to_json unless user
data = JSON.parse(request.body.read)
if user.update(data)
status 200
user.to_json
else
status 422
{ errors: user.errors }.to_json
end
end
# DELETE route
delete '/users/:id' do
user = User.find(params[:id])
halt 404 unless user
user.destroy
status 204 # No Content
end
# HEAD route - check resource existence
head '/users/:id' do
user = User.find(params[:id])
status user ? 200 : 404
end
# OPTIONS route - describe available methods
options '/users/:id' do
headers 'Allow' => 'GET, PUT, PATCH, DELETE, HEAD, OPTIONS'
status 200
end
Custom status code handling requires mapping codes to application logic. Creating a status handler module centralizes this logic and ensures consistent responses.
module StatusHandler
def handle_response(response)
case response.code.to_i
when 200..299
success_handler(response)
when 300..399
redirect_handler(response)
when 400..499
client_error_handler(response)
when 500..599
server_error_handler(response)
else
unknown_handler(response)
end
end
private
def success_handler(response)
case response.code.to_i
when 200
{ status: :ok, data: parse_body(response) }
when 201
{ status: :created, location: response['Location'] }
when 204
{ status: :no_content }
else
{ status: :success, code: response.code }
end
end
def redirect_handler(response)
location = response['Location']
case response.code.to_i
when 301
{ status: :moved_permanently, location: location }
when 302, 303
{ status: :found, location: location }
when 304
{ status: :not_modified }
else
{ status: :redirect, location: location }
end
end
def client_error_handler(response)
case response.code.to_i
when 400
{ status: :bad_request, errors: parse_errors(response) }
when 401
{ status: :unauthorized, message: 'Authentication required' }
when 403
{ status: :forbidden, message: 'Access denied' }
when 404
{ status: :not_found, message: 'Resource not found' }
when 409
{ status: :conflict, errors: parse_errors(response) }
when 422
{ status: :unprocessable_entity, errors: parse_errors(response) }
when 429
retry_after = response['Retry-After']
{ status: :too_many_requests, retry_after: retry_after }
else
{ status: :client_error, code: response.code }
end
end
def server_error_handler(response)
{ status: :server_error, code: response.code, message: response.message }
end
def parse_body(response)
JSON.parse(response.body) rescue response.body
end
def parse_errors(response)
parsed = JSON.parse(response.body)
parsed['errors'] || parsed
rescue JSON::ParserError
{ message: response.body }
end
end
Practical Examples
RESTful API client demonstrates comprehensive method usage. The client handles authentication, retries, and different response scenarios.
require 'net/http'
require 'json'
require 'uri'
class APIClient
def initialize(base_url, api_key)
@base_url = base_url
@api_key = api_key
@uri = URI(base_url)
end
def list_resources(path, params = {})
query = URI.encode_www_form(params)
uri = URI("#{@base_url}#{path}?#{query}")
request = Net::HTTP::Get.new(uri)
add_auth_header(request)
response = execute_request(uri, request)
case response.code
when '200'
JSON.parse(response.body)
when '401'
raise AuthenticationError, 'Invalid credentials'
when '404'
[] # Return empty array for missing collections
else
handle_error(response)
end
end
def get_resource(path, id)
uri = URI("#{@base_url}#{path}/#{id}")
request = Net::HTTP::Get.new(uri)
add_auth_header(request)
response = execute_request(uri, request)
case response.code
when '200'
JSON.parse(response.body)
when '304'
nil # Not modified, use cache
when '404'
raise ResourceNotFound, "Resource #{id} not found"
when '410'
raise ResourceGone, "Resource #{id} permanently deleted"
else
handle_error(response)
end
end
def create_resource(path, data)
uri = URI("#{@base_url}#{path}")
request = Net::HTTP::Post.new(uri)
add_auth_header(request)
request['Content-Type'] = 'application/json'
request.body = JSON.generate(data)
response = execute_request(uri, request)
case response.code
when '201'
location = response['Location']
{ id: extract_id(location), location: location, data: JSON.parse(response.body) }
when '400'
errors = JSON.parse(response.body)
raise ValidationError.new('Invalid data', errors)
when '409'
raise ConflictError, 'Resource already exists'
when '422'
errors = JSON.parse(response.body)
raise ValidationError.new('Unprocessable data', errors)
else
handle_error(response)
end
end
def update_resource(path, id, data)
uri = URI("#{@base_url}#{path}/#{id}")
request = Net::HTTP::Put.new(uri)
add_auth_header(request)
request['Content-Type'] = 'application/json'
request.body = JSON.generate(data)
response = execute_request(uri, request)
case response.code
when '200'
JSON.parse(response.body)
when '204'
nil # No content returned
when '404'
raise ResourceNotFound, "Resource #{id} not found"
when '409'
raise ConflictError, 'Update conflict, resource modified'
when '412'
raise PreconditionFailed, 'Precondition failed, resource changed'
else
handle_error(response)
end
end
def partial_update(path, id, changes)
uri = URI("#{@base_url}#{path}/#{id}")
request = Net::HTTP::Patch.new(uri)
add_auth_header(request)
request['Content-Type'] = 'application/json'
request.body = JSON.generate(changes)
response = execute_request(uri, request)
case response.code
when '200'
JSON.parse(response.body)
when '204'
nil
when '404'
raise ResourceNotFound
else
handle_error(response)
end
end
def delete_resource(path, id)
uri = URI("#{@base_url}#{path}/#{id}")
request = Net::HTTP::Delete.new(uri)
add_auth_header(request)
response = execute_request(uri, request)
case response.code
when '200'
JSON.parse(response.body) # Deleted resource representation
when '204'
true # Successful deletion, no content
when '404'
false # Already deleted or never existed
when '409'
raise ConflictError, 'Cannot delete, resource in use'
else
handle_error(response)
end
end
def resource_exists?(path, id)
uri = URI("#{@base_url}#{path}/#{id}")
request = Net::HTTP::Head.new(uri)
add_auth_header(request)
response = execute_request(uri, request)
response.code == '200'
end
def get_options(path)
uri = URI("#{@base_url}#{path}")
request = Net::HTTP::Options.new(uri)
add_auth_header(request)
response = execute_request(uri, request)
if response.code == '200'
{
allow: response['Allow']&.split(',')&.map(&:strip),
content_types: response['Accept']&.split(',')&.map(&:strip)
}
else
handle_error(response)
end
end
private
def execute_request(uri, request)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == 'https'
http.request(request)
end
def add_auth_header(request)
request['Authorization'] = "Bearer #{@api_key}"
end
def extract_id(location)
location.split('/').last
end
def handle_error(response)
case response.code.to_i
when 500..599
raise ServerError, "Server error: #{response.code}"
else
raise HTTPError, "HTTP error: #{response.code} #{response.message}"
end
end
class AuthenticationError < StandardError; end
class ResourceNotFound < StandardError; end
class ResourceGone < StandardError; end
class ValidationError < StandardError
attr_reader :errors
def initialize(message, errors = {})
super(message)
@errors = errors
end
end
class ConflictError < StandardError; end
class PreconditionFailed < StandardError; end
class ServerError < StandardError; end
class HTTPError < StandardError; end
end
# Usage
client = APIClient.new('https://api.example.com', 'api_key_123')
# List with pagination
users = client.list_resources('/users', page: 1, limit: 20)
# Get single resource
user = client.get_resource('/users', 123)
# Create
new_user = client.create_resource('/users', {
name: 'Alice',
email: 'alice@example.com'
})
puts "Created at: #{new_user[:location]}"
# Full update
client.update_resource('/users', 123, {
name: 'Alice Smith',
email: 'alice@example.com',
role: 'admin'
})
# Partial update
client.partial_update('/users', 123, { role: 'moderator' })
# Delete
deleted = client.delete_resource('/users', 123)
# Check existence
exists = client.resource_exists?('/users', 123)
# Get supported methods
options = client.get_options('/users')
puts "Supported methods: #{options[:allow]}"
Conditional request handling optimizes bandwidth by using ETags and modification times.
class CachedAPIClient
def initialize(base_url)
@base_url = base_url
@cache = {}
end
def get_with_cache(path)
cache_key = path
cached = @cache[cache_key]
uri = URI("#{@base_url}#{path}")
request = Net::HTTP::Get.new(uri)
if cached
# Add conditional headers
request['If-None-Match'] = cached[:etag] if cached[:etag]
request['If-Modified-Since'] = cached[:last_modified] if cached[:last_modified]
end
response = execute_request(uri, request)
case response.code
when '200'
# Update cache
@cache[cache_key] = {
data: JSON.parse(response.body),
etag: response['ETag'],
last_modified: response['Last-Modified'],
expires: response['Expires']
}
@cache[cache_key][:data]
when '304'
# Use cached data
puts "Cache hit: using stored data"
cached[:data]
when '404'
# Remove from cache if previously existed
@cache.delete(cache_key)
raise ResourceNotFound
else
handle_error(response)
end
end
def update_with_precondition(path, id, data, etag)
uri = URI("#{@base_url}#{path}/#{id}")
request = Net::HTTP::Put.new(uri)
request['Content-Type'] = 'application/json'
request['If-Match'] = etag # Optimistic locking
request.body = JSON.generate(data)
response = execute_request(uri, request)
case response.code
when '200', '204'
# Update successful
@cache.delete("#{path}/#{id}")
true
when '412'
# Precondition failed - resource modified
puts "Resource changed, cannot update"
false
when '404'
raise ResourceNotFound
else
handle_error(response)
end
end
private
def execute_request(uri, request)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == 'https'
http.request(request)
end
end
# Usage
client = CachedAPIClient.new('https://api.example.com')
# First request - cache miss
data = client.get_with_cache('/users/123')
# Second request - returns 304, uses cache
data = client.get_with_cache('/users/123')
# Update with optimistic locking
etag = 'W/"abc123"'
success = client.update_with_precondition('/users', 123, new_data, etag)
Rate limiting and retry handles 429 Too Many Requests and temporary server errors.
require 'net/http'
class RateLimitedClient
MAX_RETRIES = 3
def initialize(base_url)
@base_url = base_url
end
def request_with_retry(method, path, body: nil, retries: 0)
uri = URI("#{@base_url}#{path}")
request = build_request(method, uri, body)
response = execute_request(uri, request)
case response.code
when '200', '201', '204'
handle_success(response)
when '429'
handle_rate_limit(response, method, path, body, retries)
when '503'
handle_service_unavailable(response, method, path, body, retries)
when '500', '502', '504'
handle_server_error(response, method, path, body, retries)
else
raise HTTPError, "HTTP #{response.code}: #{response.message}"
end
end
private
def build_request(method, uri, body)
request = case method.upcase
when 'GET' then Net::HTTP::Get.new(uri)
when 'POST' then Net::HTTP::Post.new(uri)
when 'PUT' then Net::HTTP::Put.new(uri)
when 'DELETE' then Net::HTTP::Delete.new(uri)
end
if body
request['Content-Type'] = 'application/json'
request.body = JSON.generate(body)
end
request
end
def execute_request(uri, request)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == 'https'
http.request(request)
end
def handle_success(response)
return nil if response.code == '204'
JSON.parse(response.body) if response.body && !response.body.empty?
end
def handle_rate_limit(response, method, path, body, retries)
if retries >= MAX_RETRIES
raise RateLimitError, "Rate limit exceeded after #{MAX_RETRIES} retries"
end
# Check Retry-After header
retry_after = response['Retry-After']
wait_time = if retry_after
# Can be seconds or HTTP date
retry_after.to_i > 0 ? retry_after.to_i : 60
else
calculate_backoff(retries)
end
puts "Rate limited, waiting #{wait_time} seconds..."
sleep(wait_time)
request_with_retry(method, path, body: body, retries: retries + 1)
end
def handle_service_unavailable(response, method, path, body, retries)
if retries >= MAX_RETRIES
raise ServiceUnavailable, "Service unavailable after #{MAX_RETRIES} retries"
end
retry_after = response['Retry-After']&.to_i || calculate_backoff(retries)
puts "Service unavailable, retrying in #{retry_after} seconds..."
sleep(retry_after)
request_with_retry(method, path, body: body, retries: retries + 1)
end
def handle_server_error(response, method, path, body, retries)
# Don't retry non-idempotent methods unless explicitly safe
unless %w[GET HEAD PUT DELETE OPTIONS].include?(method.upcase)
raise ServerError, "Server error #{response.code}, cannot retry non-idempotent #{method}"
end
if retries >= MAX_RETRIES
raise ServerError, "Server error after #{MAX_RETRIES} retries"
end
wait_time = calculate_backoff(retries)
puts "Server error #{response.code}, retrying in #{wait_time} seconds..."
sleep(wait_time)
request_with_retry(method, path, body: body, retries: retries + 1)
end
def calculate_backoff(retry_count)
# Exponential backoff: 1s, 2s, 4s
2 ** retry_count
end
class RateLimitError < StandardError; end
class ServiceUnavailable < StandardError; end
class ServerError < StandardError; end
class HTTPError < StandardError; end
end
# Usage
client = RateLimitedClient.new('https://api.example.com')
begin
data = client.request_with_retry('GET', '/users/123')
client.request_with_retry('POST', '/users', body: {
name: 'Alice',
email: 'alice@example.com'
})
rescue RateLimitedClient::RateLimitError => e
puts "Still rate limited after retries: #{e.message}"
rescue RateLimitedClient::ServiceUnavailable => e
puts "Service down: #{e.message}"
end
Security Implications
HTTP methods carry security implications based on their semantics and typical usage patterns. Understanding these implications prevents vulnerabilities in API design and implementation.
Method-based access control restricts operations based on HTTP methods. A user might have permission to GET resources but not DELETE them. Servers must validate permissions for each method independently, not just check resource access. Failing to enforce method-level permissions allows privilege escalation where users perform unauthorized actions on accessible resources.
class SecureController < ApplicationController
before_action :authenticate_user!
before_action :authorize_action
def show
# GET - read access
@resource = Resource.find(params[:id])
render json: @resource
end
def update
# PUT/PATCH - write access
@resource = Resource.find(params[:id])
@resource.update(resource_params)
render json: @resource
end
def destroy
# DELETE - delete access
@resource = Resource.find(params[:id])
@resource.destroy
head :no_content
end
private
def authorize_action
permission = case request.method
when 'GET', 'HEAD' then :read
when 'POST' then :create
when 'PUT', 'PATCH' then :write
when 'DELETE' then :delete
else :none
end
unless current_user.can?(permission, resource_type)
render json: { error: 'Forbidden' }, status: :forbidden
end
end
def resource_type
controller_name.singularize
end
end
Safe method assumptions create vulnerabilities when violated. Applications that modify state in GET handlers break caching assumptions and enable CSRF attacks. GET requests appear in browser history, server logs, and referrer headers. Placing sensitive data or state-changing operations in GET URLs exposes them to logging and leakage. Attackers can craft malicious links that trigger actions when victims visit them.
# VULNERABLE - state change in GET
class VulnerableController < ApplicationController
def delete_account
# DO NOT DO THIS - destructive action in GET
if params[:confirm] == 'yes'
current_user.destroy
redirect_to root_path
end
end
end
# SECURE - state change requires POST/DELETE
class SecureController < ApplicationController
protect_from_forgery with: :exception
def delete_account
# Show confirmation page for GET
render :confirm_deletion
end
def destroy_account
# Actual deletion requires POST/DELETE with CSRF token
current_user.destroy
redirect_to root_path
end
end
CSRF protection prevents cross-site request forgery. Safe methods (GET, HEAD, OPTIONS) require no CSRF protection because they make no modifications. Unsafe methods (POST, PUT, PATCH, DELETE) must include CSRF tokens that attackers cannot obtain. Rails includes CSRF protection by default, but developers must not disable it for unsafe methods.
class ProtectedController < ApplicationController
# CSRF protection enabled by default for non-GET
protect_from_forgery with: :exception
# Skip CSRF for API endpoints that use token authentication
skip_before_action :verify_authenticity_token, if: :api_request?
def create
# POST protected by CSRF token
@resource = Resource.create(resource_params)
render json: @resource, status: :created
end
def update
# PUT protected by CSRF token
@resource = Resource.find(params[:id])
@resource.update(resource_params)
render json: @resource
end
private
def api_request?
# API requests use Bearer tokens instead of session cookies
request.headers['Authorization']&.start_with?('Bearer ')
end
end
# API client bypasses CSRF using Bearer token
class APIAuthController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :authenticate_token
private
def authenticate_token
token = request.headers['Authorization']&.sub(/^Bearer /, '')
@current_user = User.find_by(api_token: token)
unless @current_user
render json: { error: 'Unauthorized' }, status: :unauthorized
end
end
end
HTTP method override allows clients to tunnel PUT, PATCH, and DELETE through POST when infrastructure blocks them. This creates security risks if not properly validated. Attackers can bypass method-based security checks by sending POST with a method override header.
class MethodOverrideController < ApplicationController
# Rails handles _method parameter automatically
# But validate it doesn't bypass security
before_action :validate_method_override
private
def validate_method_override
# Ensure method override doesn't bypass authentication
if params[:_method].present?
actual_method = params[:_method].upcase
# Don't allow method override to escalate privileges
unless request.post? || request.put? || request.patch?
render json: { error: 'Method override only allowed for POST' },
status: :method_not_allowed
end
# Verify user has permission for the overridden method
unless authorized_for_method?(actual_method)
render json: { error: 'Forbidden' }, status: :forbidden
end
end
end
end
Status code information leakage occurs when error responses reveal system details. A 404 Not Found tells attackers a resource does not exist, while 403 Forbidden confirms it exists but access is denied. For unauthorized access, returning 404 instead of 403 hides resource existence.
class InformationLeakController < ApplicationController
def show
@resource = Resource.find_by(id: params[:id])
# INFORMATION LEAK - reveals whether resource exists
unless @resource
render json: { error: 'Not found' }, status: :not_found
return
end
unless current_user.can_view?(@resource)
render json: { error: 'Forbidden' }, status: :forbidden
return
end
render json: @resource
end
end
class SecureController < ApplicationController
def show
@resource = Resource.find_by(id: params[:id])
# Hide resource existence for unauthorized access
if @resource.nil? || !current_user.can_view?(@resource)
render json: { error: 'Not found' }, status: :not_found
return
end
render json: @resource
end
end
Authentication requirements differ by method. Some APIs allow anonymous GET but require authentication for POST, PUT, DELETE. Authentication checks must occur before authorization checks and apply consistently across all methods.
class AuthenticatedController < ApplicationController
# Allow anonymous read access
before_action :authenticate_user!, except: [:index, :show]
before_action :authorize_write, only: [:create, :update, :destroy]
def index
# Public - no authentication
@resources = Resource.public_scope
render json: @resources
end
def show
# Public - no authentication
@resource = Resource.public_scope.find(params[:id])
render json: @resource
end
def create
# Requires authentication and authorization
@resource = current_user.resources.create(resource_params)
render json: @resource, status: :created
end
def update
# Requires authentication and ownership
@resource = current_user.resources.find(params[:id])
@resource.update(resource_params)
render json: @resource
end
def destroy
# Requires authentication and ownership
@resource = current_user.resources.find(params[:id])
@resource.destroy
head :no_content
end
private
def authorize_write
unless current_user.can_write?
render json: { error: 'Insufficient permissions' },
status: :forbidden
end
end
end
Common Patterns
RESTful resource mapping associates HTTP methods with CRUD operations. This pattern creates consistent, predictable APIs where method semantics match operation intent.
# RESTful routes in Rails
# GET /articles -> index (list all)
# GET /articles/new -> new (form for creating)
# POST /articles -> create (create resource)
# GET /articles/:id -> show (display resource)
# GET /articles/:id/edit -> edit (form for editing)
# PUT /articles/:id -> update (replace resource)
# PATCH /articles/:id -> update (modify resource)
# DELETE /articles/:id -> destroy (delete resource)
class ArticlesController < ApplicationController
# Collection endpoints
def index
@articles = Article.all
render json: @articles
end
def create
@article = Article.new(article_params)
if @article.save
render json: @article, status: :created, location: @article
else
render json: { errors: @article.errors }, status: :unprocessable_entity
end
end
# Member endpoints
def show
@article = Article.find(params[:id])
render json: @article
end
def update
@article = Article.find(params[:id])
if @article.update(article_params)
render json: @article
else
render json: { errors: @article.errors }, status: :unprocessable_entity
end
end
def destroy
@article = Article.find(params[:id])
@article.destroy
head :no_content
end
private
def article_params
params.require(:article).permit(:title, :body, :published)
end
end
Nested resource patterns represent relationships through URI hierarchies. Methods operate within the context of parent resources.
# Nested routes
# GET /articles/:article_id/comments
# POST /articles/:article_id/comments
# GET /articles/:article_id/comments/:id
# PATCH /articles/:article_id/comments/:id
# DELETE /articles/:article_id/comments/:id
class CommentsController < ApplicationController
before_action :load_article
def index
@comments = @article.comments
render json: @comments
end
def create
@comment = @article.comments.build(comment_params)
if @comment.save
render json: @comment, status: :created,
location: [@article, @comment]
else
render json: { errors: @comment.errors },
status: :unprocessable_entity
end
end
def show
@comment = @article.comments.find(params[:id])
render json: @comment
end
def update
@comment = @article.comments.find(params[:id])
if @comment.update(comment_params)
render json: @comment
else
render json: { errors: @comment.errors },
status: :unprocessable_entity
end
end
def destroy
@comment = @article.comments.find(params[:id])
@comment.destroy
head :no_content
end
private
def load_article
@article = Article.find(params[:article_id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'Article not found' }, status: :not_found
end
def comment_params
params.require(:comment).permit(:body, :author)
end
end
Bulk operations handle multiple resources efficiently. POST to a collection endpoint can create multiple items, while custom endpoints handle bulk updates or deletes.
class BulkOperationsController < ApplicationController
# POST /articles/bulk - create multiple articles
def bulk_create
results = []
errors = []
params[:articles].each_with_index do |article_data, index|
article = Article.new(article_data.permit(:title, :body))
if article.save
results << { index: index, id: article.id, status: :created }
else
errors << { index: index, errors: article.errors }
end
end
if errors.empty?
render json: { results: results }, status: :created
else
render json: { results: results, errors: errors },
status: :multi_status
end
end
# PATCH /articles/bulk - update multiple articles
def bulk_update
results = []
params[:updates].each do |update_data|
article = Article.find_by(id: update_data[:id])
if article && article.update(update_data.permit(:title, :body))
results << { id: article.id, status: :updated }
else
results << { id: update_data[:id], status: :failed }
end
end
render json: { results: results }
end
# POST /articles/bulk_delete - delete multiple articles
def bulk_delete
ids = params[:ids]
deleted = Article.where(id: ids).destroy_all
render json: {
deleted_count: deleted.count,
deleted_ids: deleted.map(&:id)
}
end
end
Pagination patterns use query parameters with Link headers. Clients request pages through GET parameters, while responses include metadata and navigation links.
class PaginatedController < ApplicationController
def index
page = params[:page]&.to_i || 1
per_page = [params[:per_page]&.to_i || 20, 100].min
@resources = Resource.page(page).per(per_page)
# Add Link header for pagination
add_pagination_headers(@resources)
render json: {
data: @resources,
meta: {
current_page: page,
per_page: per_page,
total_pages: @resources.total_pages,
total_count: @resources.total_count
}
}
end
private
def add_pagination_headers(collection)
links = []
base_url = request.base_url + request.path
# First page
links << "<#{base_url}?page=1&per_page=#{collection.limit_value}>; rel=\"first\""
# Previous page
if collection.prev_page
links << "<#{base_url}?page=#{collection.prev_page}&per_page=#{collection.limit_value}>; rel=\"prev\""
end
# Next page
if collection.next_page
links << "<#{base_url}?page=#{collection.next_page}&per_page=#{collection.limit_value}>; rel=\"next\""
end
# Last page
links << "<#{base_url}?page=#{collection.total_pages}&per_page=#{collection.limit_value}>; rel=\"last\""
response.headers['Link'] = links.join(', ')
end
end
Idempotency keys prevent duplicate processing of non-idempotent POST requests. Clients include unique keys in headers, and servers track processed requests.
class IdempotentController < ApplicationController
before_action :check_idempotency_key, only: [:create]
def create
# Process request only once per idempotency key
@resource = Resource.create(resource_params)
# Store result for this idempotency key
Rails.cache.write(
idempotency_cache_key,
{ status: :created, id: @resource.id, data: @resource.as_json },
expires_in: 24.hours
)
render json: @resource, status: :created
end
private
def check_idempotency_key
@idempotency_key = request.headers['Idempotency-Key']
unless @idempotency_key
render json: { error: 'Idempotency-Key header required' },
status: :bad_request
return
end
# Check if we've already processed this key
cached_result = Rails.cache.read(idempotency_cache_key)
if cached_result
# Return cached response
render json: cached_result[:data], status: cached_result[:status]
end
end
def idempotency_cache_key
"idempotency:#{@idempotency_key}"
end
end
Status code handling patterns create consistent error responses across an application.
class ApplicationController < ActionController::API
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
rescue_from ActionController::ParameterMissing, with: :bad_request
private
def not_found(exception)
render json: {
error: 'Not Found',
message: exception.message
}, status: :not_found
end
def unprocessable_entity(exception)
render json: {
error: 'Unprocessable Entity',
errors: exception.record.errors.full_messages
}, status: :unprocessable_entity
end
def bad_request(exception)
render json: {
error: 'Bad Request',
message: exception.message
}, status: :bad_request
end
end
Error Handling & Edge Cases
Network timeouts occur when servers fail to respond within expected timeframes. Clients must set appropriate timeouts and handle timeout errors gracefully.
require 'net/http'
require 'timeout'
class TimeoutHandler
DEFAULT_TIMEOUT = 30
def initialize(base_url, timeout: DEFAULT_TIMEOUT)
@base_url = base_url
@timeout = timeout
end
def request(method, path, body: nil)
uri = URI("#{@base_url}#{path}")
begin
Timeout.timeout(@timeout) do
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == 'https'
http.open_timeout = @timeout / 3 # Connection timeout
http.read_timeout = @timeout # Read timeout
request = build_request(method, uri, body)
response = http.request(request)
handle_response(response)
end
rescue Timeout::Error
raise RequestTimeout, "Request timed out after #{@timeout} seconds"
rescue Net::OpenTimeout
raise ConnectionTimeout, "Connection timed out"
rescue Net::ReadTimeout
raise ReadTimeout, "Read timed out"
rescue Errno::ECONNREFUSED
raise ConnectionRefused, "Connection refused"
rescue SocketError => e
raise NetworkError, "Network error: #{e.message}"
end
end
private
def build_request(method, uri, body)
request_class = case method.upcase
when 'GET' then Net::HTTP::Get
when 'POST' then Net::HTTP::Post
when 'PUT' then Net::HTTP::Put
when 'DELETE' then Net::HTTP::Delete
else raise ArgumentError, "Unsupported method: #{method}"
end
request = request_class.new(uri)
if body
request['Content-Type'] = 'application/json'
request.body = JSON.generate(body)
end
request
end
def handle_response(response)
case response.code.to_i
when 200..299
JSON.parse(response.body) if response.body
when 408
raise RequestTimeout, "Server reported request timeout"
when 504
raise GatewayTimeout, "Gateway timeout"
else
raise HTTPError, "HTTP #{response.code}: #{response.message}"
end
end
class RequestTimeout < StandardError; end
class ConnectionTimeout < StandardError; end
class ReadTimeout < StandardError; end
class ConnectionRefused < StandardError; end
class NetworkError < StandardError; end
class GatewayTimeout < StandardError; end
class HTTPError < StandardError; end
end
Malformed responses require defensive parsing. Servers may return invalid JSON, missing headers, or unexpected content types.
class SafeResponseParser
def parse_response(response)
# Verify status code is valid
status_code = response.code.to_i
unless (100..599).include?(status_code)
raise InvalidResponse, "Invalid status code: #{response.code}"
end
# Check content type
content_type = response['Content-Type']
case status_code
when 204
# No content expected
return nil
when 205
# Reset content, no body
return nil
when 304
# Not modified, no body
return nil
else
# Parse body based on content type
parse_body(response, content_type)
end
end
private
def parse_body(response, content_type)
return nil if response.body.nil? || response.body.empty?
case content_type
when /application\/json/
parse_json(response.body)
when /text\/html/
response.body # Return as string
when /application\/xml/
parse_xml(response.body)
else
# Unknown content type
response.body
end
rescue => e
raise ParseError, "Failed to parse response: #{e.message}"
end
def parse_json(body)
JSON.parse(body)
rescue JSON::ParserError => e
raise ParseError, "Invalid JSON: #{e.message}"
end
def parse_xml(body)
# XML parsing implementation
body
end
class InvalidResponse < StandardError; end
class ParseError < StandardError; end
end
Redirect loops occur when servers return circular redirects. Clients must track visited URLs and limit redirect following.
require 'net/http'
class RedirectHandler
MAX_REDIRECTS = 5
def initialize(base_url)
@base_url = base_url
end
def get_with_redirect(path, visited: Set.new, redirect_count: 0)
if redirect_count >= MAX_REDIRECTS
raise TooManyRedirects, "Exceeded maximum redirects (#{MAX_REDIRECTS})"
end
uri = URI("#{@base_url}#{path}")
# Check for redirect loop
if visited.include?(uri.to_s)
raise RedirectLoop, "Redirect loop detected: #{uri}"
end
visited.add(uri.to_s)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == 'https'
request = Net::HTTP::Get.new(uri)
response = http.request(request)
case response.code.to_i
when 200..299
response
when 300..399
handle_redirect(response, visited, redirect_count)
else
response
end
end
private
def handle_redirect(response, visited, redirect_count)
location = response['Location']
unless location
raise MissingLocation, "Redirect without Location header"
end
case response.code.to_i
when 301, 308
# Permanent redirect - cache the new location
puts "Permanent redirect to #{location}"
follow_redirect(location, visited, redirect_count)
when 302, 303, 307
# Temporary redirect
puts "Temporary redirect to #{location}"
follow_redirect(location, visited, redirect_count)
when 304
# Not modified - no redirect
response
else
response
end
end
def follow_redirect(location, visited, redirect_count)
# Handle relative URLs
new_uri = location.start_with?('http') ? location : "#{@base_url}#{location}"
get_with_redirect(
new_uri,
visited: visited,
redirect_count: redirect_count + 1
)
end
class TooManyRedirects < StandardError; end
class RedirectLoop < StandardError; end
class MissingLocation < StandardError; end
end
Concurrent request handling prevents race conditions in state-changing operations. Using ETags with If-Match headers implements optimistic locking.
class ConcurrentUpdateHandler
def update_with_lock(resource_id, updates)
# Get current version
current = fetch_resource(resource_id)
etag = current[:etag]
# Attempt update with version check
response = send_update(resource_id, updates, etag)
case response.code
when '200', '204'
# Success
{ status: :updated, data: parse_response(response) }
when '412'
# Precondition failed - resource changed
handle_conflict(resource_id, updates, current)
when '409'
# Conflict - cannot update
{ status: :conflict, message: 'Resource conflict' }
else
{ status: :error, code: response.code }
end
end
private
def fetch_resource(id)
uri = URI("#{@base_url}/resources/#{id}")
response = Net::HTTP.get_response(uri)
{
data: JSON.parse(response.body),
etag: response['ETag']
}
end
def send_update(id, updates, etag)
uri = URI("#{@base_url}/resources/#{id}")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Put.new(uri)
request['Content-Type'] = 'application/json'
request['If-Match'] = etag
request.body = JSON.generate(updates)
http.request(request)
end
def handle_conflict(id, updates, current)
# Fetch latest version
latest = fetch_resource(id)
# Merge changes
merged = merge_updates(current[:data], latest[:data], updates)
# Retry with new ETag
response = send_update(id, merged, latest[:etag])
if response.code == '412'
# Still conflicted, fail
{ status: :conflict, message: 'Cannot resolve conflict' }
else
{ status: :updated, data: parse_response(response) }
end
end
def merge_updates(original, current, updates)
# Three-way merge logic
merged = current.dup
updates.each do |key, value|
# Only apply if field hasn't changed from original
if current[key] == original[key]
merged[key] = value
end
end
merged
end
end
Method not allowed handling returns 405 with an Allow header listing supported methods.
class MethodNotAllowedHandler < ApplicationController
rescue_from ActionController::RoutingError, with: :method_not_allowed
def method_not_allowed
allowed_methods = determine_allowed_methods
response.headers['Allow'] = allowed_methods.join(', ')
render json: {
error: 'Method Not Allowed',
allowed_methods: allowed_methods
}, status: :method_not_allowed
end
private
def determine_allowed_methods
# Inspect routes to find allowed methods for this path
routes = Rails.application.routes.routes
path = request.path
matching_routes = routes.select { |r| r.path.match(path) }
matching_routes.map { |r| r.verb }.compact.uniq
end
end
Reference
HTTP Methods
| Method | Safe | Idempotent | Cacheable | Request Body | Response Body | Common Use |
|---|---|---|---|---|---|---|
| GET | Yes | Yes | Yes | No | Yes | Retrieve resource |
| HEAD | Yes | Yes | Yes | No | No | Check resource existence |
| POST | No | No | Yes* | Yes | Yes | Create resource |
| PUT | No | Yes | No | Yes | Optional | Replace resource |
| PATCH | No | No | No | Yes | Optional | Modify resource |
| DELETE | No | Yes | No | Optional | Optional | Remove resource |
| OPTIONS | Yes | Yes | No | No | Yes | Get communication options |
| TRACE | Yes | Yes | No | No | Yes | Message loop-back test |
| CONNECT | No | No | No | Optional | Yes | Establish tunnel |
*POST cacheable only with explicit cache controls
Common Status Codes
| Code | Name | Category | Meaning | Response Body | Common Use |
|---|---|---|---|---|---|
| 200 | OK | Success | Request succeeded | Yes | Successful GET, PUT, PATCH |
| 201 | Created | Success | Resource created | Yes | Successful POST |
| 204 | No Content | Success | Success without content | No | Successful DELETE, PUT |
| 301 | Moved Permanently | Redirect | Permanent redirect | Optional | Resource moved permanently |
| 302 | Found | Redirect | Temporary redirect | Optional | Temporary redirect |
| 304 | Not Modified | Redirect | Cached version valid | No | Conditional GET unchanged |
| 400 | Bad Request | Client Error | Malformed request | Yes | Invalid syntax |
| 401 | Unauthorized | Client Error | Authentication required | Yes | Missing credentials |
| 403 | Forbidden | Client Error | Access denied | Yes | Insufficient permissions |
| 404 | Not Found | Client Error | Resource not found | Yes | Missing resource |
| 405 | Method Not Allowed | Client Error | Method unsupported | Yes | Wrong HTTP method |
| 409 | Conflict | Client Error | Request conflicts | Yes | Duplicate resource |
| 422 | Unprocessable Entity | Client Error | Semantic errors | Yes | Validation failure |
| 429 | Too Many Requests | Client Error | Rate limit exceeded | Yes | Too many requests |
| 500 | Internal Server Error | Server Error | Server failure | Yes | Unhandled error |
| 502 | Bad Gateway | Server Error | Invalid upstream response | Yes | Proxy received invalid response |
| 503 | Service Unavailable | Server Error | Temporary unavailability | Yes | Server overloaded |
| 504 | Gateway Timeout | Server Error | Upstream timeout | Yes | Proxy timeout |
Status Code Classes
| Class | Range | Meaning | Client Action |
|---|---|---|---|
| Informational | 100-199 | Request received, continuing | Continue waiting |
| Success | 200-299 | Request successful | Process response |
| Redirection | 300-399 | Further action needed | Follow redirect |
| Client Error | 400-499 | Request error | Fix and retry |
| Server Error | 500-599 | Server failure | Retry with backoff |
Method Properties
| Property | Methods | Description |
|---|---|---|
| Safe | GET, HEAD, OPTIONS, TRACE | No server state modification |
| Idempotent | GET, HEAD, PUT, DELETE, OPTIONS, TRACE | Same result when repeated |
| Cacheable | GET, HEAD, POST | Response can be cached |
Common Headers
| Header | Direction | Methods | Purpose | Example |
|---|---|---|---|---|
| Content-Type | Request/Response | POST, PUT, PATCH | Specify body format | application/json |
| Content-Length | Request/Response | POST, PUT, PATCH | Body size in bytes | 1024 |
| Location | Response | POST, 3xx | Resource location | /users/123 |
| Allow | Response | OPTIONS, 405 | Supported methods | GET, POST, PUT |
| ETag | Response | GET, HEAD | Resource version | W/"abc123" |
| If-Match | Request | PUT, PATCH, DELETE | Conditional update | W/"abc123" |
| If-None-Match | Request | GET, HEAD | Conditional request | W/"abc123" |
| Last-Modified | Response | GET, HEAD | Modification time | Wed, 21 Oct 2025 07:28:00 GMT |
| If-Modified-Since | Request | GET, HEAD | Conditional request | Wed, 21 Oct 2025 07:28:00 GMT |
| Cache-Control | Response | GET, HEAD | Caching directives | max-age=3600, private |
| Retry-After | Response | 429, 503 | Retry timing | 60 |
| Authorization | Request | All | Authentication token | Bearer abc123 |
Rails Status Code Symbols
| Symbol | Code | Usage |
|---|---|---|
| :ok | 200 | render status: :ok |
| :created | 201 | render status: :created |
| :no_content | 204 | head :no_content |
| :moved_permanently | 301 | redirect_to url, status: :moved_permanently |
| :found | 302 | redirect_to url |
| :not_modified | 304 | head :not_modified |
| :bad_request | 400 | render status: :bad_request |
| :unauthorized | 401 | render status: :unauthorized |
| :forbidden | 403 | render status: :forbidden |
| :not_found | 404 | render status: :not_found |
| :method_not_allowed | 405 | render status: :method_not_allowed |
| :conflict | 409 | render status: :conflict |
| :unprocessable_entity | 422 | render status: :unprocessable_entity |
| :too_many_requests | 429 | render status: :too_many_requests |
| :internal_server_error | 500 | render status: :internal_server_error |
| :service_unavailable | 503 | render status: :service_unavailable |
RESTful Route Conventions
| Path | Method | Action | Purpose | Status on Success |
|---|---|---|---|---|
| /resources | GET | index | List resources | 200 |
| /resources | POST | create | Create resource | 201 |
| /resources/:id | GET | show | Show resource | 200 |
| /resources/:id | PUT | update | Replace resource | 200 or 204 |
| /resources/:id | PATCH | update | Modify resource | 200 or 204 |
| /resources/:id | DELETE | destroy | Delete resource | 204 or 200 |
Net::HTTP Request Classes
| Class | Method | Body Supported |
|---|---|---|
| Net::HTTP::Get | GET | No |
| Net::HTTP::Head | HEAD | No |
| Net::HTTP::Post | POST | Yes |
| Net::HTTP::Put | PUT | Yes |
| Net::HTTP::Patch | PATCH | Yes |
| Net::HTTP::Delete | DELETE | Optional |
| Net::HTTP::Options | OPTIONS | No |
Decision Matrix: PUT vs PATCH
| Scenario | Method | Reason |
|---|---|---|
| Replace entire resource | PUT | Complete replacement |
| Update specific fields | PATCH | Partial modification |
| Client has full resource state | PUT | Can provide complete representation |
| Client has only changed fields | PATCH | Send only changes |
| Idempotency required | PUT | PUT is idempotent |
| Minimize payload size | PATCH | Send only modifications |
Error Response Format
# Validation error (422)
{
error: 'Unprocessable Entity',
errors: {
email: ['is invalid', 'has already been taken'],
name: ['is too short']
}
}
# Not found (404)
{
error: 'Not Found',
message: 'Resource with id 123 not found'
}
# Server error (500)
{
error: 'Internal Server Error',
message: 'An unexpected error occurred'
}
# Rate limit (429)
{
error: 'Too Many Requests',
message: 'Rate limit exceeded',
retry_after: 60
}