An HTTP method describes the action requested on a target resource. Clients and intermediaries use those semantics when deciding whether to fetch, cache, or retry. Choosing a familiar verb is only the beginning: the application must implement the behavior that the verb promises. Calling a destructive operation GET does not make it safe. A useful API documents its resource model, representation, authorization, and error conditions alongside the method.
Separate safety from idempotency
Safe methods have essentially read-only requested semantics. GET retrieves a representation, while HEAD asks for the corresponding response metadata without response content. Incidental effects such as access logging do not make a retrieval unsafe in the protocol sense. Charging an account because a crawler followed a GET link is different: the requested operation itself changes business state and violates the safety expectation.
Idempotency concerns the intended server effect of repeating the same request. PUT replaces the target resource’s state according to the supplied representation, and DELETE removes the association between the target and its current functionality. Repeating either should have the same intended effect as one request. The response code, log entries, or timing can differ. Idempotency does not promise immunity to concurrent writers or make an unauthorized request acceptable.
POST asks the resource to process the supplied content according to its own semantics; creation is one common use, not the entire definition. POST is not generally idempotent, though an application can provide a documented deduplication contract. PATCH describes a partial modification whose properties depend on the patch format and operation. Repeating “set this value” differs from repeating “increment this value.”
When it breaks
A retry layer that treats every timeout as permission to repeat a POST can duplicate an event or charge. Even with an idempotent method, the caller must understand concurrency and the resource’s current state. Conditional requests can guard against overwriting a representation changed by someone else. Method semantics help define the action; they do not replace application-level preconditions or transaction rules.
Likewise, GET does not mean every response is automatically reusable by every cache. Status, freshness directives, authorization, and representation selection still matter. Content in GET or HEAD requests has no generally defined semantics and may be rejected by implementations or intermediaries. Avoid treating a locally accepted request body as proof of an interoperable query interface.
A known method that is not allowed for a target can receive 405 with an Allow field identifying supported methods. That capability list does not grant authorization and is distinct from CORS permission headers. OPTIONS can describe available communication options without making an unrelated browser origin eligible to read the response.
Check it quickly
Save this as methods.py and run python3 methods.py. The standard-library fixture runs a temporary loopback server with an item resource and an event-creation endpoint. Requests are sent sequentially through actual HTTP connections, and the script checks both responses and the resulting application state. It is a small method-contract exercise, not a general production API.
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
import json
state = {'item': None, 'events': []}
allowed = 'GET, HEAD, PUT, DELETE, OPTIONS'
class Handler(BaseHTTPRequestHandler):
def reply(self, status, data=b'', extra=None, head=False):
self.send_response(status)
if status != 204:
self.send_header('Content-Length', str(len(data)))
for key, value in (extra or {}).items():
self.send_header(key, value)
self.end_headers()
if not head and status != 204:
self.wfile.write(data)
def do_GET(self):
if self.path == '/item' and state['item'] is not None:
self.reply(200, state['item'])
else:
self.reply(404, b'missing')
def do_HEAD(self):
data = state['item'] if self.path == '/item' else None
self.reply(200 if data is not None else 404, data if data is not None else b'missing', head=True)
def do_PUT(self):
if self.path != '/item':
self.reply(404, b'missing')
return
existed = state['item'] is not None
state['item'] = self.rfile.read(int(self.headers['Content-Length']))
self.reply(204 if existed else 201, extra={'Location': '/item'} if not existed else None)
def do_POST(self):
if self.path != '/events':
self.reply(405, extra={'Allow': allowed})
return
state['events'].append(self.rfile.read(int(self.headers['Content-Length'])))
self.reply(201, extra={'Location': f'/events/{len(state["events"])}'})
def do_DELETE(self):
existed = self.path == '/item' and state['item'] is not None
if existed:
state['item'] = None
self.reply(204 if existed else 404)
def do_OPTIONS(self):
self.reply(204, extra={'Allow': allowed}) if self.path == '/item' else self.reply(404)
def log_message(self, *args):
pass
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = Thread(target=server.serve_forever)
thread.start()
try:
observed = []
def request(method, path, body=None):
connection = HTTPConnection('127.0.0.1', server.server_port, timeout=5)
try:
connection.request(method, path, body)
response = connection.getresponse()
item = {'method': method, 'status': response.status, 'body': response.read().decode(),
'length': response.getheader('Content-Length'),
'location': response.getheader('Location'), 'allow': response.getheader('Allow')}
observed.append(item)
return item
finally:
connection.close()
assert request('PUT', '/item', b'first')['status'] == 201
assert request('PUT', '/item', b'first')['status'] == 204
assert state['item'] == b'first'
assert request('GET', '/item')['body'] == 'first'
head = request('HEAD', '/item')
assert head['body'] == '' and head['length'] == '5'
assert request('POST', '/events', b'same')['location'] == '/events/1'
assert request('POST', '/events', b'same')['location'] == '/events/2'
assert len(state['events']) == 2
assert request('DELETE', '/item')['status'] == 204
assert request('DELETE', '/item')['status'] == 404 and state['item'] is None
assert request('OPTIONS', '/item')['allow'] == allowed
assert request('POST', '/item', b'wrong')['status'] == 405
print(json.dumps({'responses': observed, 'final_item': state['item'], 'event_count': len(state['events'])}))
finally:
server.shutdown()
thread.join()
server.server_close()
The first PUT creates the item and returns 201. Repeating the same PUT returns 204, while the stored value remains first. GET returns those five bytes. HEAD returns no content but reports Content-Length five, showing that the field describes the corresponding GET representation rather than a HEAD message body.
Two identical POSTs create two event entries with different Location values. DELETE then returns 204, and a second DELETE returns 404 while the item stays absent. The differing responses do not contradict the idempotent deletion effect. OPTIONS reports the item’s methods, and POST to that item returns 405 with Allow.
The result demonstrates the distinctions the client must preserve. It does not exercise PATCH, caches, conditional updates, or automatic retries. Before introducing retry behavior, specify the intended repeated effect and check the server’s implementation. A standard method communicates a promise to other components; those components cannot make a dishonest handler satisfy it.
Further reading
- HTTP methods are a promise about side effects
- Content-Type should describe the bytes you actually send
Sources
- RFC 9110: HTTP Semantics, safety, idempotency, method definitions, and Allow, checked 2026-09-13.
- MDN: HTTP request methods, the purposes and properties of standard methods, checked 2026-09-13.